fix(transfer modal): remember dragged position

This commit is contained in:
Tommaso Casaburi
2026-07-02 16:07:25 +07:00
parent b4a6f0a65a
commit e50e4e630b
2 changed files with 266 additions and 3 deletions
@@ -0,0 +1,214 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PostTransferModal from '../post-transfer-modal';
(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 TRANSFER_MODAL_POSITION_SESSION_STORAGE_KEY = '5chan:transfer-modal-position';
const testState = vi.hoisted(() => ({
createAccountMock: vi.fn().mockResolvedValue(undefined),
deleteAccountMock: vi.fn().mockResolvedValue(undefined),
deleteCommentMock: vi.fn().mockResolvedValue(undefined),
directories: [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'random-nsfw.bso', directoryCode: 'b', title: '/b/ - Random' },
] as Array<{ address: string; directoryCode?: string; title?: string }>,
dragHandler: undefined as ((state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) | undefined,
onCloseMock: vi.fn(),
publishCommentMock: vi.fn().mockResolvedValue(undefined),
publishCommentModerationMock: vi.fn().mockResolvedValue(undefined),
springStartMock: vi.fn(),
useSpringMock: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}));
vi.mock('../../../hooks/use-directories', () => ({
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
useDirectories: () => testState.directories,
}));
vi.mock('../../../stores/use-challenges-store', () => ({
default: {
getState: () => ({
addChallenge: vi.fn(),
}),
},
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => ({
default: <T,>(selector: (state: { accountsActions: Record<string, unknown> }) => T) =>
selector({
accountsActions: {
createAccount: testState.createAccountMock,
deleteAccount: testState.deleteAccountMock,
deleteComment: testState.deleteCommentMock,
publishComment: testState.publishCommentMock,
publishCommentModeration: testState.publishCommentModerationMock,
},
}),
}));
vi.mock('@react-spring/web', async () => {
const React = await vi.importActual<typeof import('react')>('react');
const normalizeStyle = (style: Record<string, unknown> | undefined) =>
style
? Object.fromEntries(
Object.entries(style).map(([key, value]) => [
key,
typeof value === 'object' && value !== null && 'get' in value && typeof (value as { get: unknown }).get === 'function'
? (value as { get: () => unknown }).get()
: value,
]),
)
: undefined;
return {
animated: {
div: React.forwardRef(({ style, ...props }: any, ref) => React.createElement('div', { ...props, ref, style: normalizeStyle(style) })),
},
useSpring: testState.useSpringMock.mockImplementation(() => [
{
left: { get: () => 120 },
top: { get: () => 80 },
},
{
start: testState.springStartMock,
},
]),
};
});
vi.mock('@use-gesture/react', () => ({
useDrag: (handler: (state: { active: boolean; event: Pick<Event, 'preventDefault'>; offset: [number, number] }) => void) => {
testState.dragHandler = handler;
return () => ({});
},
}));
let container: HTMLDivElement;
let root: Root;
const baseComment = {
author: { displayName: 'Alice' },
cid: 'comment-1',
content: 'Original content',
communityAddress: 'music-posting.eth',
number: 42,
parentCid: undefined,
postCid: 'post-1',
title: 'Subject',
} as any;
const renderTransferModal = async (comment = baseComment) => {
await act(async () => {
root.render(createElement(PostTransferModal, { comment, onClose: testState.onCloseMock }));
});
};
describe('PostTransferModal', () => {
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
testState.dragHandler = undefined;
testState.springStartMock.mockReset();
testState.useSpringMock.mockReset();
testState.useSpringMock.mockImplementation(() => [
{
left: { get: () => 120 },
top: { get: () => 80 },
},
{
start: testState.springStartMock,
},
]);
window.sessionStorage.clear();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
document.body.innerHTML = '';
window.sessionStorage.clear();
});
it('positions the draggable transfer modal with left/top styles instead of a transform layer', async () => {
await renderTransferModal();
const modal = document.body.querySelector<HTMLDivElement>('[role="dialog"]');
expect(modal?.style.left).toBe('120px');
expect(modal?.style.top).toBe('80px');
expect(modal?.style.transform).toBe('');
});
it('reopens desktop transfer modals at the last dragged session position', async () => {
await renderTransferModal();
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(TRANSFER_MODAL_POSITION_SESSION_STORAGE_KEY) ?? '{}')).toEqual({ left: 232, top: 147 });
await act(async () => {
root.render(createElement(React.Fragment));
});
testState.springStartMock.mockClear();
testState.useSpringMock.mockClear();
await renderTransferModal({ ...baseComment, cid: 'comment-2', number: 43 });
const [configFactory] = testState.useSpringMock.mock.calls[0] as [() => Record<string, unknown>, unknown[]];
expect(configFactory()).toEqual({
from: {
left: 232,
top: 147,
},
});
expect(testState.springStartMock).not.toHaveBeenCalled();
});
it('ignores the stored desktop position when first opened in a mobile viewport', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
window.sessionStorage.setItem(TRANSFER_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 renderTransferModal();
const [configFactory] = testState.useSpringMock.mock.calls[0] as [() => Record<string, unknown>, unknown[]];
expect(configFactory()).toEqual({
from: {
left: 10,
top: 380,
},
});
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight });
}
});
});
@@ -1,4 +1,4 @@
import React, { useEffect, useEffectEvent, useLayoutEffect, useMemo, useReducer, useRef } from 'react';
import React, { useEffect, useEffectEvent, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useSpring, animated } from '@react-spring/web';
@@ -35,9 +35,11 @@ type DeleteCommentAction = (commentCidOrAccountCommentIndex: string | number, ac
type DeleteAccountAction = (accountName?: string) => Promise<void>;
type PublishCommentModerationAction = (publishCommentModerationOptions: Record<string, unknown>, accountName?: string) => Promise<void>;
type TransferModalPosition = { left: number; top: number };
type InitialTransferModalPosition = { isStored: boolean; position: TransferModalPosition };
const TRANSFER_MODAL_WIDTH_PX = 430;
const TRANSFER_MODAL_VIEWPORT_GUTTER_PX = 20;
const TRANSFER_MODAL_POSITION_SESSION_STORAGE_KEY = '5chan:transfer-modal-position';
interface TransferModalState {
targetBoardAddress: string;
@@ -126,6 +128,47 @@ const getCenteredTransferModalPosition = (modalElement?: HTMLElement | null): Tr
};
};
const readTransferModalPosition = (): TransferModalPosition | null => {
if (typeof window === 'undefined') return null;
try {
const storedPosition = window.sessionStorage.getItem(TRANSFER_MODAL_POSITION_SESSION_STORAGE_KEY);
if (!storedPosition) return null;
const parsedPosition = JSON.parse(storedPosition) as Partial<TransferModalPosition>;
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 transfer modal position from sessionStorage:', error);
return null;
}
};
const writeTransferModalPosition = (position: TransferModalPosition) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(TRANSFER_MODAL_POSITION_SESSION_STORAGE_KEY, JSON.stringify(position));
} catch (error) {
console.warn('Failed to save transfer modal position to sessionStorage:', error);
}
};
const shouldUseStoredTransferModalPosition = () => typeof window !== 'undefined' && window.innerWidth >= 640;
const getInitialTransferModalPosition = (): InitialTransferModalPosition => {
const centeredPosition = getCenteredTransferModalPosition();
if (!shouldUseStoredTransferModalPosition()) return { isStored: false, position: centeredPosition };
const storedPosition = readTransferModalPosition();
return storedPosition ? { isStored: true, position: storedPosition } : { isStored: false, position: centeredPosition };
};
const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransferSuccess }: PostTransferModalProps) => {
const { t } = useTranslation();
const nodeRef = useRef<HTMLDivElement>(null);
@@ -174,16 +217,19 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
typeof deleteAccount === 'function' &&
hasSelectedTransferFields(selectedFields, availableFields);
const [initialModalPosition] = useState(getInitialTransferModalPosition);
const [{ left, top }, api] = useSpring(
() => ({
from: getCenteredTransferModalPosition(),
from: initialModalPosition.position,
}),
[],
);
useLayoutEffect(() => {
if (initialModalPosition.isStored) return;
api.start({ ...getCenteredTransferModalPosition(nodeRef.current), immediate: true });
}, [api]);
}, [api, initialModalPosition.isStored]);
const disableBodyTextSelection = () => {
if (!bodySelectionStyleBeforeDragRef.current) {
@@ -214,6 +260,9 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
disableBodyTextSelection();
} else {
restoreBodyTextSelection();
if (shouldUseStoredTransferModalPosition()) {
writeTransferModalPosition({ left: nextLeft, top: nextTop });
}
}
api.start({ left: nextLeft, top: nextTop, immediate: true });
},