///
import assert from 'node:assert/strict';
import * as React from 'react';
import type {
HTMLAttributes,
PointerEvent,
ReactElement,
ReactNode,
} from 'react';
import { afterAll, afterEach, test, vi } from 'vite-plus/test';
import type { SuggestedUser, User } from '../../resources/js/types';
let showPreview = false;
const stateChanges: unknown[] = [];
vi.doMock('react', () => ({
...React,
useEffect: () => {},
useLayoutEffect: () => {},
useRef: (initial: unknown) => ({ current: initial }),
useState: (initial: unknown) => [
typeof initial === 'function'
? initial()
: initial === null && showPreview
? { left: 0, top: 0 }
: initial,
(value: unknown) => stateChanges.push(value),
],
useSyncExternalStore: (_subscribe: unknown, read: () => unknown) => read(),
}));
vi.doMock('react-dom', async () => ({
...(await vi.importActual('react-dom')),
createPortal: (children: ReactNode) => children,
}));
const { GraphRail } = await import('../../resources/js/components/graph-rail');
const { UserAvatar } =
await import('../../resources/js/components/user-avatar');
const { ActorAvatar } =
await import('../../resources/js/pages/notification/index');
const { UserPreview, clearUserPreviews } =
await import('../../resources/js/components/user-preview');
const { SuggestedUserRow } =
await import('../../resources/js/components/who-to-follow');
vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) });
vi.stubGlobal('document', { body: {} });
afterAll(() => vi.unstubAllGlobals());
afterEach(() => {
vi.useRealTimers();
stateChanges.length = 0;
});
const surfaces = {
rail: (onHoverChange: (hovering: boolean) => void) =>
GraphRail({ lane: 'red', onHoverChange }).props,
'profile preview trigger': (onHoverChange: (hovering: boolean) => void) => {
const preview = UserPreview({
user: { handle: 'author' },
children: 'Author',
onHoverChange,
});
return (
React.Children.toArray(preview.props.children)[0] as ReactElement<
HTMLAttributes
>
).props;
},
'profile preview panel': (onHoverChange: (hovering: boolean) => void) => {
showPreview = true;
const preview = UserPreview({
user: { id: 'author', handle: 'author' } as User,
children: 'Author',
onHoverChange,
});
showPreview = false;
return (
React.Children.toArray(preview.props.children)[1] as ReactElement<
HTMLAttributes
>
).props;
},
};
for (const [name, render] of Object.entries(surfaces)) {
for (const pointerType of ['touch', 'pen']) {
test(`${name} ignores ${pointerType} and compatibility mouse hover`, () => {
const changes: boolean[] = [];
const props = render((hovering) => changes.push(hovering));
props.onPointerEnter?.({
pointerType,
} as PointerEvent);
props.onPointerLeave?.({
pointerType,
} as PointerEvent);
props.onMouseEnter?.({} as React.MouseEvent);
assert.deepEqual(changes, []);
});
}
test(`${name} highlights the author only while a mouse is over it`, () => {
const changes: boolean[] = [];
const props = render((hovering) => changes.push(hovering));
props.onPointerEnter?.({
pointerType: 'mouse',
} as PointerEvent);
props.onPointerLeave?.({
pointerType: 'mouse',
} as PointerEvent);
assert.deepEqual(changes, [true, false]);
});
}
function renderPreviewPanel() {
showPreview = true;
const preview = UserPreview({
user: { id: 'author', handle: 'author' } as User,
children: 'Author',
});
showPreview = false;
const panel = React.Children.toArray(
preview.props.children,
)[1] as ReactElement<
HTMLAttributes & {
children: ReactElement<{
onMenuOpenChange: (isOpen: boolean) => void;
}>;
}
>;
return {
...panel.props,
onMenuOpenChange: panel.props.children.props.onMenuOpenChange,
};
}
test('profile preview closes when its menu is dismissed after the mouse left', () => {
vi.useFakeTimers();
const panel = renderPreviewPanel();
panel.onPointerEnter?.({
pointerType: 'mouse',
} as PointerEvent);
panel.onMenuOpenChange(true);
panel.onPointerLeave?.({
pointerType: 'mouse',
} as PointerEvent);
vi.runAllTimers();
stateChanges.length = 0;
panel.onMenuOpenChange(false);
vi.runAllTimers();
assert.ok(stateChanges.includes(false));
});
test('profile preview stays open when its menu is dismissed under the mouse', () => {
vi.useFakeTimers();
const panel = renderPreviewPanel();
panel.onPointerEnter?.({
pointerType: 'mouse',
} as PointerEvent);
panel.onMenuOpenChange(true);
stateChanges.length = 0;
panel.onMenuOpenChange(false);
vi.runAllTimers();
assert.ok(!stateChanges.includes(false));
});
test('profile preview opens without the cached relationship once previews are cleared', () => {
const user = { id: 'muted', handle: 'muted' } as User;
const content = () => {
showPreview = true;
const preview = UserPreview({ user, children: 'Muted' });
showPreview = false;
return (
React.Children.toArray(preview.props.children)[1] as ReactElement<{
children: ReactElement<{
data: { followsYou: boolean; isMuting: boolean } | null;
onChange: (data: object) => void;
}>;
}>
).props.children.props;
};
content().onChange({
isFollowing: false,
followsYou: true,
isMuting: true,
isBlocking: false,
isUnavailable: false,
user,
});
assert.equal(content().data?.isMuting, true);
clearUserPreviews();
assert.equal(content().data, null);
});
test('suggested user row wraps the author in a user preview anchored to their avatar', () => {
const user = {
id: 'author',
name: 'Author',
handle: 'author',
avatar_url: null,
lane: 1,
timezone: null,
show_local_time: false,
followed_by_following_count: 15,
followers_count: 20,
} as SuggestedUser;
type PreviewProps = {
user: SuggestedUser;
anchor: React.RefObject;
children?: ReactNode;
};
const findPreview = (
node: ReactNode,
): ReactElement | undefined =>
React.Children.toArray(node)
.filter((child) => React.isValidElement(child))
.map((child) =>
child.type === UserPreview
? child
: findPreview(child.props.children),
)
.find(Boolean);
const preview = findPreview(SuggestedUserRow({ user }));
assert.ok(preview);
assert.deepEqual(preview.props.user, user);
assert.ok(preview.props.anchor);
});
const actor = {
id: 'actor',
name: 'Actor',
handle: 'actor',
avatar_url: null,
lane: 1,
} as User;
type ActorPreview = ReactElement<{
user: User;
anchor: React.RefObject;
children: ReactElement<{
ref: React.RefObject;
children: ReactElement<{ user: User }>;
}>;
}>;
test('notification actor avatar previews the actor around the avatar it anchors', () => {
const anchor = { current: null };
const preview = ActorAvatar({ user: actor, anchor }) as ActorPreview;
const avatar = preview.props.children;
assert.equal(preview.type, UserPreview);
assert.equal(preview.props.user, actor);
assert.equal(preview.props.anchor, anchor);
assert.equal(avatar.props.ref, anchor);
assert.equal(avatar.props.children.type, UserAvatar);
assert.equal(avatar.props.children.props.user, actor);
});