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
2 changes: 1 addition & 1 deletion src/app/shell/ProfileButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ it("opens the viewer's profile from the menu avatar and hands focus to the page"
);
// A keyboard opening lands on the avatar as the first item. The item and the
// button it renders must activate once between them, not on the opening key.
expect(item).toHaveFocus();
await waitFor(() => expect(item).toHaveFocus());
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("menuitem", { name: "Settings" })).toHaveFocus();
await user.keyboard("{ArrowUp}");
Expand Down
10 changes: 10 additions & 0 deletions src/bundled/channel-templates/agent-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1674,3 +1674,13 @@ it.each([
}
},
);

beforeEach(() => {
// jsdom hides [popover] but has no native top layer. Browser tests own paint.
HTMLElement.prototype.showPopover = function () {
this.style.display = "block";
};
});
afterEach(() => {
Reflect.deleteProperty(HTMLElement.prototype, "showPopover");
});
12 changes: 11 additions & 1 deletion src/bundled/channels/ChannelMembersDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import { npubEncode } from "nostr-tools/nip19";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { beforeEach, afterEach, expect, it, vi } from "vitest";
import { createRelaySession } from "../../features/relay/session";
import { keypair, profile, roster, signed } from "../../features/relay/testing";
import { matchesEvent } from "../../features/relay/projection";
Expand Down Expand Up @@ -461,3 +461,13 @@ it("keeps known members quiet while rechecking membership without enabling unver
await act(async () => release());
}
});

beforeEach(() => {
// jsdom hides [popover] but has no native top layer. Browser tests own paint.
HTMLElement.prototype.showPopover = function () {
this.style.display = "block";
};
});
afterEach(() => {
Reflect.deleteProperty(HTMLElement.prototype, "showPopover");
});
133 changes: 124 additions & 9 deletions src/features/messages/ChannelTimeline.restore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ vi.mock("virtua", async () => {
};
});
const frames = new Map<number, FrameRequestCallback>();
const resizes = new Set<() => void>();
let nextFrame = 0;
const owners: { dispose(): void }[] = [];
beforeEach(() => {
Expand All @@ -50,8 +51,13 @@ beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
constructor(private callback: () => void) {}
observe() {
resizes.add(this.callback);
}
disconnect() {
resizes.delete(this.callback);
}
},
);
});
Expand All @@ -71,7 +77,7 @@ async function frame() {
}
});
}
function mount(bottom = false) {
function mount(bottom = false, revealOnMount = false) {
const viewer = keypair(),
relay = keypair();
const target = message(viewer, "c", "Saved reading anchor", 1);
Expand Down Expand Up @@ -118,18 +124,27 @@ function mount(bottom = false) {
/>
</StrictMode>
);
const result = render(tree(window));
const first = window.rows[0];
if (!first) throw new Error("Missing fixture row");
const sent = { ...first, id: "sent", content: "New message" };
const sentWindow = { ...window, rows: [...window.rows, sent] };
const result = render(
revealOnMount ? tree(sentWindow, sent.id) : tree(window),
);
return {
saved: { offset: 900, bottom, anchor: { id: target.id, y: 42 } },
unmount: result.unmount,
reveal() {
const first = window.rows[0];
if (!first) throw new Error("Missing fixture row");
const sent = { ...first, id: "sent", content: "New message" };
result.rerender(tree(sentWindow, sent.id));
},
refreshSent() {
result.rerender(
tree({ ...window, rows: [...window.rows, sent] }, sent.id),
tree({ ...sentWindow, rows: [...sentWindow.rows] }, sent.id),
);
},
awaitReveal() {
result.rerender(tree(window, sent.id));
},
replaceAnchor() {
const first = window.rows[0];
if (!first) throw new Error("Missing fixture row");
Expand Down Expand Up @@ -192,10 +207,110 @@ it.each([false, true])(
await h.measured();
await frame();
expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { align: "end" });
expect(scroll.toIndex.mock.calls.length).toBe(calls + (bottom ? 1 : 0));
expect(scroll.toIndex.mock.calls.length).toBe(calls + 1);
},
);

it.each(["none", "wheel", "key"])(
"a reveal on mount follows late measurements unless reader input=%s",
async (input) => {
const h = mount(false, true);
await frame();
expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { align: "end" });
scroll.toIndex.mockClear();
const feed = screen.getByRole("region", {
name: "Channel message history",
});
if (input === "wheel") fireEvent.wheel(feed);
if (input === "key") fireEvent.keyDown(feed, { key: "PageUp" });
await h.measured();
await frame();
if (input === "none") {
expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { align: "end" });
h.unmount();
expect(readView("scope", "scroll:c", null)).toMatchObject({
bottom: true,
});
} else expect(scroll.toIndex).not.toHaveBeenCalled();
},
);

it.each(["wheel", "key"])(
"reader input before the reveal frame stays authoritative after a row refresh: %s",
async (input) => {
const h = mount();
await frame();
scroll.toIndex.mockClear();
h.reveal();
const feed = screen.getByRole("region", {
name: "Channel message history",
});
if (input === "wheel") fireEvent.wheel(feed);
else fireEvent.keyDown(feed, { key: "PageUp" });
await frame();
expect(scroll.toIndex).not.toHaveBeenCalled();
// A later echo/edit replaces rows; it must not revive the canceled reveal.
h.refreshSent();
await frame();
expect(scroll.toIndex).not.toHaveBeenCalled();
},
);

it("waits for a sent row to arrive without restoring over its reveal", async () => {
const h = mount();
await frame();
h.awaitReveal();
await frame();
expect(scroll.toIndex).not.toHaveBeenCalledWith(1, { align: "end" });
h.reveal();
await frame();
expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { align: "end" });
scroll.toIndex.mockClear();
h.reveal();
await frame();
expect(scroll.toIndex).not.toHaveBeenCalled();
});

it.each([false, true])(
"the first intermediate bottom-follow scroll retains intent unless reader input=%s",
async (readerInput) => {
const h = mount(true);
await frame();
const feed = screen.getByRole("region", {
name: "Channel message history",
});
// Virtua can deliver an intermediate offset before measuring its final range.
feed.scrollTop = 162;
if (readerInput) fireEvent.wheel(feed);
fireEvent.scroll(feed);
scroll.toIndex.mockClear();
await h.measured();
await frame();
if (readerInput) expect(scroll.toIndex).not.toHaveBeenCalled();
else expect(scroll.toIndex).toHaveBeenLastCalledWith(0, { align: "end" });
},
);

it("a resize before the local reveal scroll event cannot revive the prior reading anchor", async () => {
const h = mount();
await frame();
h.reveal();
await frame();
expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { align: "end" });
scroll.toIndex.mockClear();
// Composer/sidebar layout may resize before the browser delivers scroll.
await act(async () => {
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(500);
for (const resize of resizes) resize();
});
await frame();
expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { align: "end" });
expect(scroll.toIndex).not.toHaveBeenCalledWith(0, {
align: "start",
offset: -42,
});
});

it.each([false, true])(
"a scroll before any visible virtual row mounts retains restoration unless reader input=%s",
async (readerInput) => {
Expand Down
59 changes: 32 additions & 27 deletions src/features/messages/ChannelTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,8 @@ function Timeline({
element.clientHeight === previous.viewport &&
element.scrollTop <
previous.offset + Math.min(0, element.scrollHeight - previous.height);
if (previous && follow.current && !movedUp) position.bottom = true;
if (follow.current && !movedUp && (previous || !userScrolled.current))
position.bottom = true;
// Restoration can scroll before Virtua measures rows beneath the anchor,
// briefly reaching the estimated bottom. Only reader input may follow.
if (restoredAnchor.current) position.bottom = false;
Expand Down Expand Up @@ -336,9 +337,20 @@ function Timeline({
};
// Initial signature only; mutations invalidate the saved cache on remount.
}, [channelId, geometry, scope]);
const revealed = useRef<string | undefined>(undefined);
useLayoutEffect(() => {
// Row updates include edits/reactions/replies, not only new message IDs.
// Above-bottom reading and prepend anchoring remain Virtua's responsibility.
const revealIndex =
revealMessageId && revealed.current !== revealMessageId
? rows.findIndex(
(row) =>
row.id === revealMessageId ||
row.membershipRows?.some(
(member) => member.id === revealMessageId,
),
)
: -1;
const previousIds = edges.current.ids;
const arrivals = prepend
? 0
Expand All @@ -361,9 +373,20 @@ function Timeline({
!size.width ||
!size.height ||
!rows.length ||
(settled.current && (!follow.current || prepend))
(revealIndex < 0 && settled.current && (!follow.current || prepend))
)
return;
// A send supersedes saved reading intent before the first scroll event.
// This effect also retains its height observer through late measurements.
if (revealIndex >= 0) {
revealed.current = revealMessageId;
intent.current++;
follow.current = true;
restoredAnchor.current = undefined;
savedPosition.current = { offset: 0, bottom: true };
measuredPosition.current = null;
userScrolled.current = false;
}
// virtua attaches its scroller in an effect; wait through the StrictMode probe.
// A new gesture wins over restoration queued before that gesture.
const scheduledIntent = intent.current;
Expand Down Expand Up @@ -393,7 +416,10 @@ function Timeline({
} else handle.current.scrollTo(restore.offset);
follow.current = false;
} else if (follow.current) {
handle.current.scrollToIndex(rows.length - 1, { align: "end" });
handle.current.scrollToIndex(
revealIndex >= 0 ? revealIndex : rows.length - 1,
{ align: "end" },
);
}
};
let frame = requestAnimationFrame(() => {
Expand Down Expand Up @@ -465,30 +491,8 @@ function Timeline({
navigation,
exactRevealed,
updateJumpToLatest,
revealMessageId,
]);
const revealed = useRef<string | undefined>(undefined);
useLayoutEffect(() => {
if (!width || !revealMessageId || revealed.current === revealMessageId)
return;
const index = rows.findIndex(
(row) =>
row.id === revealMessageId ||
row.membershipRows?.some((member) => member.id === revealMessageId),
);
if (index < 0) return;
// A local send is explicit navigation intent, even when reading older messages.
// Wait for the optimistic row and virtualizer to mount before revealing it.
const frame = requestAnimationFrame(() => {
if (!handle.current) return;
if (!follow.current) intent.current++;
follow.current = true;
restoredAnchor.current = undefined;
userScrolled.current = false;
handle.current.scrollToIndex(index, { align: "end" });
revealed.current = revealMessageId;
});
return () => cancelAnimationFrame(frame);
}, [revealMessageId, rows, width]);
const loadNearTop = useCallback(
(element: HTMLElement, resume = false) => {
olderDemand.current = false;
Expand Down Expand Up @@ -529,15 +533,16 @@ function Timeline({
const gesture = () => {
restoredAnchor.current = undefined;
intent.current++;
if (scroller.current) recordPosition(scroller.current);
userScrolled.current = true;
if (scroller.current) recordPosition(scroller.current);
// At a restored top edge, input cannot move the DOM and emits no scroll.
if (scroller.current && scroller.current.scrollTop <= 0)
loadNearTop(scroller.current);
};
return (
<section
ref={scroller}
data-message-scroller
className={styles.feed}
data-channel-timeline={channelId}
onWheel={gesture}
Expand Down
4 changes: 2 additions & 2 deletions src/features/messages/MediaAttachment.video.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ it("offers inline seek, speed and volume controls and hands off the scrubbed pos
});
expect(video.currentTime).toBe(32);
fireEvent.click(screen.getByRole("button", { name: "Playback speed: 1x" }));
fireEvent.click(screen.getByRole("button", { name: "0.25x" }));
fireEvent.click(screen.getByRole("menuitemradio", { name: "0.25x" }));
expect(video.playbackRate).toBe(0.25);
fireEvent.change(screen.getByRole("slider", { name: "Video volume" }), {
target: { value: "0.25" },
Expand Down Expand Up @@ -176,7 +176,7 @@ it("shares the saved playback speed with mounted previews and later videos", ()
})[0];
if (!firstSpeed) throw new Error("Missing speed control");
fireEvent.click(firstSpeed);
fireEvent.click(screen.getByRole("button", { name: "1.75x" }));
fireEvent.click(screen.getByRole("menuitemradio", { name: "1.75x" }));
expect(
[...container.querySelectorAll("video")].map((video) => video.playbackRate),
).toEqual([1.75, 1.75]);
Expand Down
1 change: 1 addition & 0 deletions src/features/messages/MediaReviewViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ function ReviewComments({
return (
<section
ref={comments}
data-message-scroller
className={styles.mediaReviewThread}
aria-label="Media comments"
>
Expand Down
Loading
Loading