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 { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSpring, animated } from '@react-spring/web';
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
@@ -35,9 +35,11 @@ type DeleteCommentAction = (commentCidOrAccountCommentIndex: string | number, ac
|
|||||||
type DeleteAccountAction = (accountName?: string) => Promise<void>;
|
type DeleteAccountAction = (accountName?: string) => Promise<void>;
|
||||||
type PublishCommentModerationAction = (publishCommentModerationOptions: Record<string, unknown>, accountName?: string) => Promise<void>;
|
type PublishCommentModerationAction = (publishCommentModerationOptions: Record<string, unknown>, accountName?: string) => Promise<void>;
|
||||||
type TransferModalPosition = { left: number; top: number };
|
type TransferModalPosition = { left: number; top: number };
|
||||||
|
type InitialTransferModalPosition = { isStored: boolean; position: TransferModalPosition };
|
||||||
|
|
||||||
const TRANSFER_MODAL_WIDTH_PX = 430;
|
const TRANSFER_MODAL_WIDTH_PX = 430;
|
||||||
const TRANSFER_MODAL_VIEWPORT_GUTTER_PX = 20;
|
const TRANSFER_MODAL_VIEWPORT_GUTTER_PX = 20;
|
||||||
|
const TRANSFER_MODAL_POSITION_SESSION_STORAGE_KEY = '5chan:transfer-modal-position';
|
||||||
|
|
||||||
interface TransferModalState {
|
interface TransferModalState {
|
||||||
targetBoardAddress: string;
|
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 PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransferSuccess }: PostTransferModalProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const nodeRef = useRef<HTMLDivElement>(null);
|
const nodeRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -174,16 +217,19 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
|
|||||||
typeof deleteAccount === 'function' &&
|
typeof deleteAccount === 'function' &&
|
||||||
hasSelectedTransferFields(selectedFields, availableFields);
|
hasSelectedTransferFields(selectedFields, availableFields);
|
||||||
|
|
||||||
|
const [initialModalPosition] = useState(getInitialTransferModalPosition);
|
||||||
const [{ left, top }, api] = useSpring(
|
const [{ left, top }, api] = useSpring(
|
||||||
() => ({
|
() => ({
|
||||||
from: getCenteredTransferModalPosition(),
|
from: initialModalPosition.position,
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (initialModalPosition.isStored) return;
|
||||||
|
|
||||||
api.start({ ...getCenteredTransferModalPosition(nodeRef.current), immediate: true });
|
api.start({ ...getCenteredTransferModalPosition(nodeRef.current), immediate: true });
|
||||||
}, [api]);
|
}, [api, initialModalPosition.isStored]);
|
||||||
|
|
||||||
const disableBodyTextSelection = () => {
|
const disableBodyTextSelection = () => {
|
||||||
if (!bodySelectionStyleBeforeDragRef.current) {
|
if (!bodySelectionStyleBeforeDragRef.current) {
|
||||||
@@ -214,6 +260,9 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
|
|||||||
disableBodyTextSelection();
|
disableBodyTextSelection();
|
||||||
} else {
|
} else {
|
||||||
restoreBodyTextSelection();
|
restoreBodyTextSelection();
|
||||||
|
if (shouldUseStoredTransferModalPosition()) {
|
||||||
|
writeTransferModalPosition({ left: nextLeft, top: nextTop });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
api.start({ left: nextLeft, top: nextTop, immediate: true });
|
api.start({ left: nextLeft, top: nextTop, immediate: true });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user