mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user