fix(reply modal): remember desktop position across opens in session

Dragged reply modal positions persist in sessionStorage for the tab and restore on the next desktop open, while mobile viewports still center the modal.
This commit is contained in:
Tommaso Casaburi
2026-07-01 22:51:30 +07:00
parent fde4f0ac6a
commit 546ee99f12
2 changed files with 113 additions and 4 deletions
@@ -9,6 +9,7 @@ import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-option
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const REPLY_MODAL_POSITION_SESSION_STORAGE_KEY = '5chan:reply-modal-position';
const testState = vi.hoisted(() => ({
account: { author: { address: 'alice.eth', displayName: 'Alice' } } as { author?: { address?: string; displayName?: string } },
@@ -486,6 +487,7 @@ describe('ReplyModal', () => {
testState.uploadedFileName = null;
testState.uploadMode = 'always';
testState.mediaHostingRuntime = 'web';
window.sessionStorage.clear();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
@@ -496,6 +498,7 @@ describe('ReplyModal', () => {
container.remove();
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
window.sessionStorage.clear();
vi.unstubAllGlobals();
});
@@ -1448,6 +1451,64 @@ describe('ReplyModal', () => {
expect(modal?.style.touchAction).toBe('none');
});
it('reopens desktop reply modals at the last dragged session position', async () => {
await renderReplyModal('/mu/thread/post-1');
await act(async () => {
testState.dragHandler?.({
active: true,
event: { preventDefault: vi.fn() },
offset: [232.4, 146.6],
});
testState.dragHandler?.({
active: false,
event: { preventDefault: vi.fn() },
offset: [232.4, 146.6],
});
});
expect(JSON.parse(window.sessionStorage.getItem(REPLY_MODAL_POSITION_SESSION_STORAGE_KEY) ?? '{}')).toEqual({ left: 232, top: 147 });
await act(async () => {
root.render(createElement(React.Fragment));
});
testState.useSpringMock.mockClear();
await renderReplyModal('/b/thread/post-2', 'random-nsfw.bso');
const [configFactory] = testState.useSpringMock.mock.calls[0] as [() => Record<string, unknown>, unknown[]];
expect(configFactory()).toEqual({
from: {
left: 232,
top: 147,
},
});
});
it('ignores the stored desktop position when first opened in a mobile viewport', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
testState.isMobile = true;
window.sessionStorage.setItem(REPLY_MODAL_POSITION_SESSION_STORAGE_KEY, JSON.stringify({ left: 500, top: 210 }));
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 760 });
try {
await renderReplyModal('/mu/thread/post-1');
const [configFactory] = testState.useSpringMock.mock.calls[0] as [() => Record<string, unknown>, unknown[]];
expect(configFactory()).toEqual({
from: {
left: 45,
top: 180,
},
});
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
it('closes with Escape from the document on desktop', async () => {
await renderReplyModal('/mu/thread/post-1');
+52 -4
View File
@@ -58,6 +58,53 @@ import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
const REPLY_MODAL_POSITION_SESSION_STORAGE_KEY = '5chan:reply-modal-position';
type ReplyModalPosition = {
left: number;
top: number;
};
const getCenteredReplyModalPosition = (): ReplyModalPosition => ({
left: Math.round(window.innerWidth / 2 - 150),
top: Math.round(window.innerHeight / 2 - 200),
});
const readReplyModalPosition = (): ReplyModalPosition | null => {
try {
const storedPosition = window.sessionStorage.getItem(REPLY_MODAL_POSITION_SESSION_STORAGE_KEY);
if (!storedPosition) return null;
const parsedPosition = JSON.parse(storedPosition) as Partial<ReplyModalPosition>;
if (typeof parsedPosition.left !== 'number' || typeof parsedPosition.top !== 'number') return null;
if (!Number.isFinite(parsedPosition.left) || !Number.isFinite(parsedPosition.top)) return null;
return {
left: Math.round(parsedPosition.left),
top: Math.round(parsedPosition.top),
};
} catch (error) {
console.warn('Failed to read reply modal position from sessionStorage:', error);
return null;
}
};
const writeReplyModalPosition = (position: ReplyModalPosition) => {
try {
window.sessionStorage.setItem(REPLY_MODAL_POSITION_SESSION_STORAGE_KEY, JSON.stringify(position));
} catch (error) {
console.warn('Failed to save reply modal position to sessionStorage:', error);
}
};
const shouldUseStoredReplyModalPosition = () => window.innerWidth >= 640;
const getInitialReplyModalPosition = (): ReplyModalPosition => {
const centeredPosition = getCenteredReplyModalPosition();
if (!shouldUseStoredReplyModalPosition()) return centeredPosition;
return readReplyModalPosition() ?? centeredPosition;
};
interface ReplyModalProps {
closeModal: () => void;
@@ -239,13 +286,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const nodeRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const [initialModalPosition] = useState(getInitialReplyModalPosition);
const [{ left, top }, api] = useSpring(
() => ({
from: {
left: Math.round(window.innerWidth / 2 - 150),
top: Math.round(window.innerHeight / 2 - 200),
},
from: initialModalPosition,
}),
[],
);
@@ -281,6 +326,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
disableBodyTextSelection();
} else {
restoreBodyTextSelection();
if (!isMobile) {
writeReplyModalPosition({ left: nextLeft, top: nextTop });
}
}
api.start({ left: nextLeft, top: nextTop, immediate: true });
},