mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(transfer modal): remember dragged position
This commit is contained in:
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user