mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(composer): preserve drafts by location (#1186)
* fix(composer): preserve drafts by location * fix(composer): address review feedback * fix(composer): remount drafts on location changes
This commit is contained in:
@@ -100,7 +100,15 @@ vi.mock('../stores/use-create-board-modal-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../stores/use-reply-modal-store', () => ({
|
vi.mock('../stores/use-reply-modal-store', () => ({
|
||||||
default: () => testState.replyModalState,
|
default: <T,>(selector?: (state: { closeModal: ReplyModalShape['closeModal']; modals: Record<string, ReplyModalShape> }) => T) => {
|
||||||
|
const state = {
|
||||||
|
closeModal: testState.replyModalState.closeModal,
|
||||||
|
modals: new Proxy({} as Record<string, ReplyModalShape>, {
|
||||||
|
get: () => testState.replyModalState,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return selector ? selector(state) : (state as T);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../stores/use-special-theme-store', () => ({
|
vi.mock('../stores/use-special-theme-store', () => ({
|
||||||
|
|||||||
+17
-25
@@ -1,5 +1,4 @@
|
|||||||
import { lazy, Suspense, useEffect } from 'react';
|
import { lazy, Suspense, useCallback, useEffect } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||||
import { useAccount, useAccountComment, useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
import { useAccount, useAccountComment, useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import { initSnow, removeSnow, shouldShowSnow } from './lib/snow';
|
import { initSnow, removeSnow, shouldShowSnow } from './lib/snow';
|
||||||
@@ -19,6 +18,7 @@ import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './ho
|
|||||||
import useSuspendOffscreenMediaPlayback from './hooks/use-suspend-offscreen-media-playback';
|
import useSuspendOffscreenMediaPlayback from './hooks/use-suspend-offscreen-media-playback';
|
||||||
import { normalizeAccountCommentIndex } from './lib/utils/account-comment-index-utils';
|
import { normalizeAccountCommentIndex } from './lib/utils/account-comment-index-utils';
|
||||||
import { getCommentCommunityAddress } from './lib/utils/comment-utils';
|
import { getCommentCommunityAddress } from './lib/utils/comment-utils';
|
||||||
|
import { getPageDraftKey } from './lib/utils/location-draft-utils';
|
||||||
import {
|
import {
|
||||||
getBoardPath,
|
getBoardPath,
|
||||||
isBoardModRoute,
|
isBoardModRoute,
|
||||||
@@ -203,29 +203,19 @@ const GlobalLayout = () => {
|
|||||||
useTheme({ applyDocumentEffects: true });
|
useTheme({ applyDocumentEffects: true });
|
||||||
useSuspendOffscreenMediaPlayback();
|
useSuspendOffscreenMediaPlayback();
|
||||||
|
|
||||||
const {
|
const location = useLocation();
|
||||||
activeCid,
|
const { pathname } = location;
|
||||||
parentNumber,
|
const locationDraftKey = getPageDraftKey(location);
|
||||||
threadNumber,
|
const modal = useReplyModalStore((state) => state.modals[locationDraftKey]);
|
||||||
threadCid,
|
const closeReplyModal = useReplyModalStore((state) => state.closeModal);
|
||||||
communityAddress: activeCommunityAddress,
|
const closeModal = useCallback(() => closeReplyModal(locationDraftKey), [closeReplyModal, locationDraftKey]);
|
||||||
closeModal,
|
const activeCid = modal?.activeCid;
|
||||||
showReplyModal,
|
const parentNumber = modal?.parentNumber ?? null;
|
||||||
scrollY,
|
const threadNumber = modal?.threadNumber ?? null;
|
||||||
} = useReplyModalStore(
|
const threadCid = modal?.threadCid;
|
||||||
useShallow((state) => ({
|
const activeCommunityAddress = modal?.communityAddress;
|
||||||
activeCid: state.activeCid,
|
const showReplyModal = modal?.showReplyModal ?? false;
|
||||||
parentNumber: state.parentNumber,
|
const scrollY = modal?.scrollY ?? 0;
|
||||||
threadNumber: state.threadNumber,
|
|
||||||
threadCid: state.threadCid,
|
|
||||||
communityAddress: state.communityAddress,
|
|
||||||
closeModal: state.closeModal,
|
|
||||||
showReplyModal: state.showReplyModal,
|
|
||||||
scrollY: state.scrollY,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const isInSettingsView = pathname.endsWith('/settings');
|
const isInSettingsView = pathname.endsWith('/settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -240,7 +230,9 @@ const GlobalLayout = () => {
|
|||||||
{activeCid && threadCid && activeCommunityAddress && (
|
{activeCid && threadCid && activeCommunityAddress && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ReplyModal
|
<ReplyModal
|
||||||
|
key={locationDraftKey}
|
||||||
closeModal={closeModal}
|
closeModal={closeModal}
|
||||||
|
locationDraftKey={locationDraftKey}
|
||||||
parentCid={activeCid}
|
parentCid={activeCid}
|
||||||
parentNumber={parentNumber}
|
parentNumber={parentNumber}
|
||||||
threadNumber={threadNumber}
|
threadNumber={threadNumber}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ describe('footer', () => {
|
|||||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('post-cid', 42, 'music-posting.eth');
|
expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('/all/thread/post-cid', 'post-cid', 42, 'music-posting.eth');
|
||||||
expect(container.querySelector('[data-testid="post-page-stats"]')?.textContent).toBe('post-page-stats');
|
expect(container.querySelector('[data-testid="post-page-stats"]')?.textContent).toBe('post-page-stats');
|
||||||
|
|
||||||
testState.openReplyModalEmptyMock.mockReset();
|
testState.openReplyModalEmptyMock.mockReset();
|
||||||
@@ -248,7 +248,7 @@ describe('footer', () => {
|
|||||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('post-cid', 55, 'music-posting.eth');
|
expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('/mu/thread/post-1', 'post-cid', 55, 'music-posting.eth');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to link stats and unknown counts when thread data is unavailable or closed on mobile', async () => {
|
it('falls back to link stats and unknown counts when thread data is unavailable or closed on mobile', async () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { shouldShowCatalogButton } from '../board-buttons/catalog-button-utils';
|
import { shouldShowCatalogButton } from '../board-buttons/catalog-button-utils';
|
||||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||||
|
import { getPageDraftKey } from '../../lib/utils/location-draft-utils';
|
||||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||||
import useOptimisticReplyCount from '../../hooks/use-optimistic-reply-count';
|
import useOptimisticReplyCount from '../../hooks/use-optimistic-reply-count';
|
||||||
@@ -168,7 +169,7 @@ export const ThreadFooterFirstRow = ({ postCid, threadNumber, communityAddress,
|
|||||||
|
|
||||||
const handlePostReplyClick = () => {
|
const handlePostReplyClick = () => {
|
||||||
if (isThreadClosed) return;
|
if (isThreadClosed) return;
|
||||||
openReplyModalEmpty(postCid, threadNumber, communityAddress);
|
openReplyModalEmpty(getPageDraftKey(location), postCid, threadNumber, communityAddress);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -259,7 +260,7 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, communityAddress, is
|
|||||||
|
|
||||||
const handlePostReplyClick = () => {
|
const handlePostReplyClick = () => {
|
||||||
if (isThreadClosed) return;
|
if (isThreadClosed) return;
|
||||||
openReplyModalEmpty(postCid, threadNumber, communityAddress);
|
openReplyModalEmpty(getPageDraftKey(location), postCid, threadNumber, communityAddress);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { create } from 'zustand';
|
|||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import { shouldShowSnow } from '../../lib/snow';
|
import { shouldShowSnow } from '../../lib/snow';
|
||||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||||
|
import { getPageDraftKey } from '../../lib/utils/location-draft-utils';
|
||||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||||
@@ -237,7 +238,7 @@ const PostInfo = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cid && postCid && communityAddress && openReplyModal) {
|
if (cid && postCid && communityAddress && openReplyModal) {
|
||||||
openReplyModal(cid, post?.number, postCid, threadNumber, communityAddress);
|
openReplyModal(getPageDraftKey(location), cid, post?.number, postCid, threadNumber, communityAddress);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import PostForm, { LinkTypePreviewer } from '../post-form';
|
import PostForm, { LinkTypePreviewer } from '../post-form';
|
||||||
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
|
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
|
||||||
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
||||||
|
import usePostFormDraftsStore from '../../../stores/use-post-form-drafts-store';
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(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 act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
@@ -477,7 +478,15 @@ const renderNavigablePostForm = async (initialEntry: string) => {
|
|||||||
React.Fragment,
|
React.Fragment,
|
||||||
{},
|
{},
|
||||||
createElement(Link, { to: '/biz' }, 'go_biz'),
|
createElement(Link, { to: '/biz' }, 'go_biz'),
|
||||||
createElement(Routes, {}, createElement(Route, { path: '/:boardIdentifier/*', element: createElement(KeyedPostForm) })),
|
createElement(Link, { to: '/biz?search=first' }, 'go_biz_first_search'),
|
||||||
|
createElement(Link, { to: '/biz?search=second' }, 'go_biz_second_search'),
|
||||||
|
createElement(Link, { to: '/biz/thread/thread-cid' }, 'go_thread'),
|
||||||
|
createElement(
|
||||||
|
Routes,
|
||||||
|
{},
|
||||||
|
createElement(Route, { path: '/:boardIdentifier/thread/:commentCid/*', element: createElement(KeyedPostForm) }),
|
||||||
|
createElement(Route, { path: '/:boardIdentifier/*', element: createElement(KeyedPostForm) }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -536,6 +545,7 @@ const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, val
|
|||||||
describe('PostForm', () => {
|
describe('PostForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
usePostFormDraftsStore.setState({ forms: {} });
|
||||||
testState.account = {
|
testState.account = {
|
||||||
author: { address: 'alice.eth', displayName: 'Alice' },
|
author: { address: 'alice.eth', displayName: 'Alice' },
|
||||||
subscriptions: ['music-posting.eth'],
|
subscriptions: ['music-posting.eth'],
|
||||||
@@ -1053,38 +1063,74 @@ describe('PostForm', () => {
|
|||||||
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(false);
|
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops stale thread content when board navigation remounts the form before a link-only post', async () => {
|
it('restores each location post form visibility and draft after navigation', async () => {
|
||||||
await renderNavigablePostForm('/mu');
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||||
|
await renderNavigablePostForm('/biz');
|
||||||
await clickByText(container, 'start_new_thread');
|
await clickByText(container, 'start_new_thread');
|
||||||
|
|
||||||
let table = container.querySelector('table');
|
let table = container.querySelector('table');
|
||||||
let textarea = table?.querySelector('textarea');
|
const textarea = table?.querySelector('textarea');
|
||||||
|
const optionsInput = table?.querySelector<HTMLInputElement>('[aria-label="options"]');
|
||||||
|
const subjectInput = table?.querySelector<HTMLInputElement>('[aria-label="subject"]');
|
||||||
|
const linkInput = table?.querySelector<HTMLInputElement>('[aria-label="link"]');
|
||||||
|
const spoilerInput = table?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||||
|
|
||||||
expect(table).toBeTruthy();
|
expect(table).toBeTruthy();
|
||||||
expect(textarea).toBeTruthy();
|
expect(textarea).toBeTruthy();
|
||||||
|
expect(optionsInput).toBeTruthy();
|
||||||
|
expect(subjectInput).toBeTruthy();
|
||||||
|
expect(linkInput).toBeTruthy();
|
||||||
|
expect(spoilerInput).toBeTruthy();
|
||||||
|
|
||||||
await dispatchInput(textarea as HTMLTextAreaElement, 'stale draft body');
|
await dispatchInput(optionsInput as HTMLInputElement, 'nonoko');
|
||||||
expect(testState.publishPostOptions.content).toBe('stale draft body');
|
await dispatchInput(subjectInput as HTMLInputElement, 'Saved subject');
|
||||||
|
await dispatchInput(textarea as HTMLTextAreaElement, 'saved board draft');
|
||||||
|
await dispatchInput(linkInput as HTMLInputElement, 'https://example.com/saved.png');
|
||||||
|
await act(async () => {
|
||||||
|
spoilerInput?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await clickLinkByText(container, 'go_thread');
|
||||||
|
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.querySelector('table')).toBeNull();
|
||||||
|
|
||||||
|
await clickByText(container, 'post_a_reply');
|
||||||
|
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('');
|
||||||
|
|
||||||
await clickLinkByText(container, 'go_biz');
|
await clickLinkByText(container, 'go_biz');
|
||||||
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await clickByText(container, 'start_new_thread');
|
|
||||||
|
|
||||||
table = container.querySelector('table');
|
table = container.querySelector('table');
|
||||||
textarea = table?.querySelector('textarea');
|
expect(table).toBeTruthy();
|
||||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
expect(table?.querySelector<HTMLInputElement>('[aria-label="options"]')?.value).toBe('nonoko');
|
||||||
const linkInput = textInputs[3];
|
expect(table?.querySelector<HTMLInputElement>('[aria-label="subject"]')?.value).toBe('Saved subject');
|
||||||
|
expect(table?.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('saved board draft');
|
||||||
|
expect(table?.querySelector<HTMLInputElement>('[aria-label="link"]')?.value).toBe('https://example.com/saved.png');
|
||||||
|
expect(table?.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(true);
|
||||||
|
|
||||||
expect(textarea?.value).toBe('');
|
|
||||||
expect(linkInput).toBeTruthy();
|
|
||||||
|
|
||||||
await dispatchInput(linkInput as HTMLInputElement, 'https://example.com/fresh.png');
|
|
||||||
await clickByText(table as HTMLTableElement, 'post');
|
await clickByText(table as HTMLTableElement, 'post');
|
||||||
|
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
||||||
|
content: 'saved board draft',
|
||||||
|
link: 'https://example.com/saved.png',
|
||||||
|
spoiler: true,
|
||||||
|
title: 'Saved subject',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
it('restores the correct post draft when only the location search changes', async () => {
|
||||||
expect(testState.publishedPostOptions?.link).toBe('https://example.com/fresh.png');
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||||
expect(testState.publishedPostOptions?.content).toBeUndefined();
|
await renderNavigablePostForm('/biz?search=first');
|
||||||
|
await clickByText(container, 'start_new_thread');
|
||||||
|
await dispatchInput(container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement, 'first search draft');
|
||||||
|
|
||||||
|
await clickLinkByText(container, 'go_biz_second_search');
|
||||||
|
await clickByText(container, 'start_new_thread');
|
||||||
|
await dispatchInput(container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement, 'second search draft');
|
||||||
|
|
||||||
|
await clickLinkByText(container, 'go_biz_first_search');
|
||||||
|
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('first search draft');
|
||||||
|
|
||||||
|
await clickLinkByText(container, 'go_biz_second_search');
|
||||||
|
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('second search draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a 4chan-style flag field on flag boards and publishes the default geographic request', async () => {
|
it('shows a 4chan-style flag field on flag boards and publishes the default geographic request', async () => {
|
||||||
@@ -1674,7 +1720,8 @@ describe('PostForm', () => {
|
|||||||
await clickByText(container, 'start_new_thread');
|
await clickByText(container, 'start_new_thread');
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
|
|
||||||
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalled();
|
||||||
|
expect(usePostFormDraftsStore.getState().forms['/mu']).toBeUndefined();
|
||||||
expect(testState.navigateMock).toHaveBeenCalledWith('/pending/7', { state: { boardPath: 'mu' } });
|
expect(testState.navigateMock).toHaveBeenCalledWith('/pending/7', { state: { boardPath: 'mu' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1697,7 +1744,7 @@ describe('PostForm', () => {
|
|||||||
await flushEffects();
|
await flushEffects();
|
||||||
|
|
||||||
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
||||||
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalled();
|
||||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu', { state: { nonokoPendingAccountCommentIndex: 7 } });
|
expect(testState.navigateMock).toHaveBeenCalledWith('/mu', { state: { nonokoPendingAccountCommentIndex: 7 } });
|
||||||
expect(testState.navigateMock).not.toHaveBeenCalledWith('/pending/7');
|
expect(testState.navigateMock).not.toHaveBeenCalledWith('/pending/7');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
|
|||||||
import { isValidURL } from '../../lib/utils/url-utils';
|
import { isValidURL } from '../../lib/utils/url-utils';
|
||||||
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
|
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
|
||||||
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
||||||
|
import { getPageDraftKey } from '../../lib/utils/location-draft-utils';
|
||||||
import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
|
import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
|
||||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
|
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
|
||||||
@@ -57,6 +58,7 @@ import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/sho
|
|||||||
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
|
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
|
||||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||||
|
import usePostFormDraftsStore, { EMPTY_POST_FORM_STATE, type PostFormDraft } from '../../stores/use-post-form-drafts-store';
|
||||||
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
||||||
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
||||||
import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
|
import LoadingEllipsis from '../loading-ellipsis/loading-ellipsis';
|
||||||
@@ -202,6 +204,8 @@ interface PostFormFieldsProps {
|
|||||||
onOekakiClearUploadedUrl: (url: string) => void;
|
onOekakiClearUploadedUrl: (url: string) => void;
|
||||||
isPublishSubmissionInFlight: boolean;
|
isPublishSubmissionInFlight: boolean;
|
||||||
disableReplyPublish: boolean;
|
disableReplyPublish: boolean;
|
||||||
|
draft: PostFormDraft;
|
||||||
|
updateDraft: (draft: Partial<PostFormDraft>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostFormFields = ({
|
const PostFormFields = ({
|
||||||
@@ -257,6 +261,8 @@ const PostFormFields = ({
|
|||||||
onOekakiClearUploadedUrl,
|
onOekakiClearUploadedUrl,
|
||||||
isPublishSubmissionInFlight,
|
isPublishSubmissionInFlight,
|
||||||
disableReplyPublish,
|
disableReplyPublish,
|
||||||
|
draft,
|
||||||
|
updateDraft,
|
||||||
}: PostFormFieldsProps) => (
|
}: PostFormFieldsProps) => (
|
||||||
<>
|
<>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -294,7 +300,16 @@ const PostFormFields = ({
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{t('options')}</td>
|
<td>{t('options')}</td>
|
||||||
<td>
|
<td>
|
||||||
<input type='text' aria-label={t('options')} ref={optionsRef} autoCorrect='off' autoComplete='off' spellCheck='false' onChange={handleOptionsChange} />
|
<input
|
||||||
|
type='text'
|
||||||
|
aria-label={t('options')}
|
||||||
|
ref={optionsRef}
|
||||||
|
autoCorrect='off'
|
||||||
|
autoComplete='off'
|
||||||
|
spellCheck='false'
|
||||||
|
defaultValue={draft.options}
|
||||||
|
onChange={handleOptionsChange}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{!isInPostView && (
|
{!isInPostView && (
|
||||||
@@ -305,7 +320,9 @@ const PostFormFields = ({
|
|||||||
type='text'
|
type='text'
|
||||||
aria-label={t('subject')}
|
aria-label={t('subject')}
|
||||||
ref={subjectRef}
|
ref={subjectRef}
|
||||||
|
defaultValue={draft.title}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
updateDraft({ title: e.target.value });
|
||||||
setPublishPostOptions({ title: e.target.value });
|
setPublishPostOptions({ title: e.target.value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -350,6 +367,7 @@ const PostFormFields = ({
|
|||||||
ref={textRef}
|
ref={textRef}
|
||||||
aria-label={t('comment')}
|
aria-label={t('comment')}
|
||||||
hidden={showBbcodeToolbar && isBbcodePreviewing}
|
hidden={showBbcodeToolbar && isBbcodePreviewing}
|
||||||
|
defaultValue={draft.content}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -364,7 +382,8 @@ const PostFormFields = ({
|
|||||||
aria-label={t('flag')}
|
aria-label={t('flag')}
|
||||||
className={styles.flagSelector}
|
className={styles.flagSelector}
|
||||||
ref={flagRef}
|
ref={flagRef}
|
||||||
defaultValue={flagOptions[0]?.value}
|
defaultValue={draft.flag ?? flagOptions[0]?.value}
|
||||||
|
onChange={(event) => updateDraft({ flag: event.target.value })}
|
||||||
>
|
>
|
||||||
{flagOptions.map((option) => (
|
{flagOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -387,6 +406,7 @@ const PostFormFields = ({
|
|||||||
placeholder={requireCurrentLinkIsMedia ? FILE_LINK_PLACEHOLDER : undefined}
|
placeholder={requireCurrentLinkIsMedia ? FILE_LINK_PLACEHOLDER : undefined}
|
||||||
ref={urlRef}
|
ref={urlRef}
|
||||||
disabled={disableLinkInput}
|
disabled={disableLinkInput}
|
||||||
|
defaultValue={draft.link}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleLinkChange(e.target.value);
|
handleLinkChange(e.target.value);
|
||||||
}}
|
}}
|
||||||
@@ -425,7 +445,14 @@ const PostFormFields = ({
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{t('tag')}</td>
|
<td>{t('tag')}</td>
|
||||||
<td>
|
<td>
|
||||||
<select name='flashTag' aria-label={t('tag')} className={styles.flagSelector} ref={flashTagRef} defaultValue=''>
|
<select
|
||||||
|
name='flashTag'
|
||||||
|
aria-label={t('tag')}
|
||||||
|
className={styles.flagSelector}
|
||||||
|
ref={flashTagRef}
|
||||||
|
defaultValue={draft.flashTag}
|
||||||
|
onChange={(event) => updateDraft({ flashTag: event.target.value })}
|
||||||
|
>
|
||||||
<option value=''>{t('choose_one')}</option>
|
<option value=''>{t('choose_one')}</option>
|
||||||
{flashTagOptions.map((option) => (
|
{flashTagOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -453,7 +480,15 @@ const PostFormFields = ({
|
|||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
aria-label={capitalize(t('spoiler'))}
|
aria-label={capitalize(t('spoiler'))}
|
||||||
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
|
defaultChecked={draft.spoiler}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateDraft({ spoiler: e.target.checked });
|
||||||
|
if (isInPostView) {
|
||||||
|
setPublishReplyOptions({ spoiler: e.target.checked });
|
||||||
|
} else {
|
||||||
|
setPublishPostOptions({ spoiler: e.target.checked });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{capitalize(t('spoiler'))}?
|
{capitalize(t('spoiler'))}?
|
||||||
</label>
|
</label>
|
||||||
@@ -468,8 +503,11 @@ const PostFormFields = ({
|
|||||||
<select
|
<select
|
||||||
aria-label={t('board')}
|
aria-label={t('board')}
|
||||||
className={styles.boardSelector}
|
className={styles.boardSelector}
|
||||||
onChange={(e) => setPublishPostOptions({ communityAddress: e.target.value })}
|
onChange={(e) => {
|
||||||
value={communityAddress}
|
updateDraft({ communityAddress: e.target.value || undefined });
|
||||||
|
setPublishPostOptions({ communityAddress: e.target.value });
|
||||||
|
}}
|
||||||
|
value={communityAddress || ''}
|
||||||
>
|
>
|
||||||
<option value=''>{t('choose_one')}</option>
|
<option value=''>{t('choose_one')}</option>
|
||||||
{isInAllView &&
|
{isInAllView &&
|
||||||
@@ -549,11 +587,16 @@ const PostFormErrorRow = ({ ariaLive, children }: { ariaLive?: 'polite'; childre
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
||||||
const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: string }) => {
|
const PostFormTable = ({ closeForm, draftKey, postCid }: { closeForm: () => void; draftKey: string; postCid: string }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const location = useLocation();
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const [url, setUrl] = useState('');
|
const draft = usePostFormDraftsStore((state) => state.forms[draftKey]?.draft ?? EMPTY_POST_FORM_STATE.draft);
|
||||||
|
const updateStoredDraft = usePostFormDraftsStore((state) => state.updateDraft);
|
||||||
|
const updateDraft = useCallback((nextDraft: Partial<PostFormDraft>) => updateStoredDraft(draftKey, nextDraft), [draftKey, updateStoredDraft]);
|
||||||
|
const hasRestoredDraftRef = useRef(Boolean(draft.communityAddress || draft.content || draft.link || draft.options || draft.spoiler || draft.title));
|
||||||
|
const [url, setUrl] = useState(draft.link);
|
||||||
const author = account?.author || {};
|
const author = account?.author || {};
|
||||||
const { displayName } = author || {};
|
const { displayName } = author || {};
|
||||||
const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
||||||
@@ -562,7 +605,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const { setPublishPostOptions, postIndex, publishPost, publishPostError, publishPostOptions, resetPublishPostOptions } = usePublishPost({
|
const { setPublishPostOptions, postIndex, publishPost, publishPostError, publishPostOptions, resetPublishPostOptions } = usePublishPost({
|
||||||
communityAddress,
|
communityAddress,
|
||||||
});
|
});
|
||||||
const effectiveBoardAddress = communityAddress || publishPostOptions.communityAddress;
|
const effectiveBoardAddress = communityAddress || draft.communityAddress || publishPostOptions.communityAddress;
|
||||||
|
|
||||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const urlRef = useRef<HTMLInputElement>(null);
|
const urlRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -574,7 +617,6 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const diceRollRef = useRef<DiceRoll | null>(null);
|
const diceRollRef = useRef<DiceRoll | null>(null);
|
||||||
const nonokoRedirectPathRef = useRef<string | null>(null);
|
const nonokoRedirectPathRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const isInPostView = isPostPageView(location.pathname, params);
|
const isInPostView = isPostPageView(location.pathname, params);
|
||||||
const isInAllView = isAllView(location.pathname);
|
const isInAllView = isAllView(location.pathname);
|
||||||
const isInModView = isModView(location.pathname);
|
const isInModView = isModView(location.pathname);
|
||||||
@@ -724,7 +766,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) {
|
if ((isInAllView || isInSubscriptionsView || isInModView) && !draft.communityAddress) {
|
||||||
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
|
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -738,7 +780,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
};
|
};
|
||||||
|
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||||
await publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
|
await publishPost({
|
||||||
|
content: publishContent,
|
||||||
|
...getPublishLinkOptions(currentUrl, appliedYouTubeConversion || (hasRestoredDraftRef.current && Boolean(currentUrl))),
|
||||||
|
...publishOptions,
|
||||||
|
...(hasRestoredDraftRef.current && draft.communityAddress ? { communityAddress: draft.communityAddress } : {}),
|
||||||
|
...(hasRestoredDraftRef.current && draft.spoiler ? { spoiler: true } : {}),
|
||||||
|
...(hasRestoredDraftRef.current && currentTitle ? { title: currentTitle } : {}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// redirect to pending page when pending comment is created
|
// redirect to pending page when pending comment is created
|
||||||
@@ -749,13 +798,14 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
nonokoRedirectPathRef.current = null;
|
nonokoRedirectPathRef.current = null;
|
||||||
resetPublishPostOptions();
|
resetPublishPostOptions();
|
||||||
resetFields();
|
resetFields();
|
||||||
|
closeForm();
|
||||||
if (nonokoRedirectPath) {
|
if (nonokoRedirectPath) {
|
||||||
navigate(nonokoRedirectPath, { state: getNonokoPendingRouteState(postIndex) });
|
navigate(nonokoRedirectPath, { state: getNonokoPendingRouteState(postIndex) });
|
||||||
} else {
|
} else {
|
||||||
navigate(`/pending/${postIndex}`, pendingPostBoardPath ? { state: { boardPath: pendingPostBoardPath } } : undefined);
|
navigate(`/pending/${postIndex}`, pendingPostBoardPath ? { state: { boardPath: pendingPostBoardPath } } : undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, navigate]);
|
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, closeForm, navigate]);
|
||||||
|
|
||||||
// in post page, publish a reply to the post
|
// in post page, publish a reply to the post
|
||||||
const cid = params?.commentCid || '';
|
const cid = params?.commentCid || '';
|
||||||
@@ -775,6 +825,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
}, [checkContentLength, checkPostOptions, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]);
|
}, [checkContentLength, checkPostOptions, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]);
|
||||||
|
|
||||||
const handleContentValueChange = (content: string, options = optionsRef.current?.value || '') => {
|
const handleContentValueChange = (content: string, options = optionsRef.current?.value || '') => {
|
||||||
|
updateDraft({ content });
|
||||||
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, { includeFortune: false });
|
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, { includeFortune: false });
|
||||||
if (isBbcodePreviewing) {
|
if (isBbcodePreviewing) {
|
||||||
setBbcodePreviewContent(content);
|
setBbcodePreviewContent(content);
|
||||||
@@ -800,6 +851,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
|
|
||||||
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const options = e.target.value;
|
const options = e.target.value;
|
||||||
|
updateDraft({ options });
|
||||||
handleContentValueChange(textRef.current?.value || '', options);
|
handleContentValueChange(textRef.current?.value || '', options);
|
||||||
setFormError((currentError) => (isPostOptionsValidationError(currentError) ? null : currentError));
|
setFormError((currentError) => (isPostOptionsValidationError(currentError) ? null : currentError));
|
||||||
checkPostOptions(options, postOptionsDirectoryCode);
|
checkPostOptions(options, postOptionsDirectoryCode);
|
||||||
@@ -869,11 +921,17 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||||
|
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||||
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
await publishReply({
|
||||||
|
content: publishContent,
|
||||||
|
...getPublishLinkOptions(currentUrl, appliedYouTubeConversion || (hasRestoredDraftRef.current && Boolean(currentUrl))),
|
||||||
|
...flagPublishOptions,
|
||||||
|
...(hasRestoredDraftRef.current && draft.spoiler ? { spoiler: true } : {}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const setLinkValue = (nextUrl: string) => {
|
const setLinkValue = (nextUrl: string) => {
|
||||||
setUrl(nextUrl);
|
setUrl(nextUrl);
|
||||||
|
updateDraft({ link: nextUrl });
|
||||||
if (isInPostView) {
|
if (isInPostView) {
|
||||||
setPublishReplyOptions({ link: nextUrl });
|
setPublishReplyOptions({ link: nextUrl });
|
||||||
} else {
|
} else {
|
||||||
@@ -1000,7 +1058,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
directories={directories}
|
directories={directories}
|
||||||
accountCommunityAddresses={accountCommunityAddresses}
|
accountCommunityAddresses={accountCommunityAddresses}
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
communityAddress={communityAddress}
|
communityAddress={effectiveBoardAddress}
|
||||||
rulesPath={rulesPath}
|
rulesPath={rulesPath}
|
||||||
requireCurrentLinkIsMedia={requireCurrentLinkIsMedia}
|
requireCurrentLinkIsMedia={requireCurrentLinkIsMedia}
|
||||||
flagOptions={flagOptions}
|
flagOptions={flagOptions}
|
||||||
@@ -1018,6 +1076,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
|
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
|
||||||
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||||
disableReplyPublish={isResolvingExternalQuotes}
|
disableReplyPublish={isResolvingExternalQuotes}
|
||||||
|
draft={draft}
|
||||||
|
updateDraft={updateDraft}
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
@@ -1050,6 +1110,18 @@ const PostForm = () => {
|
|||||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const draftKey = getPageDraftKey(location);
|
||||||
|
const showForm = usePostFormDraftsStore((state) => state.forms[draftKey]?.isOpen ?? false);
|
||||||
|
const openForm = usePostFormDraftsStore((state) => state.openForm);
|
||||||
|
const clearForm = usePostFormDraftsStore((state) => state.clearForm);
|
||||||
|
const closeForm = useCallback(() => clearForm(draftKey), [clearForm, draftKey]);
|
||||||
|
const toggleForm = useCallback(() => {
|
||||||
|
if (showForm) {
|
||||||
|
closeForm();
|
||||||
|
} else {
|
||||||
|
openForm(draftKey);
|
||||||
|
}
|
||||||
|
}, [closeForm, draftKey, openForm, showForm]);
|
||||||
|
|
||||||
const commentCid = params?.commentCid;
|
const commentCid = params?.commentCid;
|
||||||
const post = useCommunitiesPagesStore((state) => (commentCid ? state.comments[commentCid] : undefined));
|
const post = useCommunitiesPagesStore((state) => (commentCid ? state.comments[commentCid] : undefined));
|
||||||
@@ -1065,8 +1137,6 @@ const PostForm = () => {
|
|||||||
const isThreadClosed = deleted || locked || removed || archived;
|
const isThreadClosed = deleted || locked || removed || archived;
|
||||||
const threadStateKey = archived ? 'thread_archived' : 'thread_closed';
|
const threadStateKey = archived ? 'thread_archived' : 'thread_closed';
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
|
|
||||||
const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
const accountComment = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
||||||
const resolvedAddress = useResolvedCommunityAddress();
|
const resolvedAddress = useResolvedCommunityAddress();
|
||||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||||
@@ -1087,10 +1157,10 @@ const PostForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button type='button' className={`${styles.showFormButton} button`} onClick={() => setShowForm(showForm ? false : true)}>
|
<button type='button' className={`${styles.showFormButton} button`} onClick={toggleForm}>
|
||||||
{showForm ? t('close_post_form') : isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
{showForm ? t('close_post_form') : isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||||
</button>
|
</button>
|
||||||
{showForm && <PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />}
|
{showForm && <PostFormTable key={draftKey} closeForm={closeForm} draftKey={draftKey} postCid={postCid} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isInCatalogView && <hr />}
|
{isInCatalogView && <hr />}
|
||||||
@@ -1112,13 +1182,13 @@ const PostForm = () => {
|
|||||||
) : !showForm ? (
|
) : !showForm ? (
|
||||||
<div>
|
<div>
|
||||||
[
|
[
|
||||||
<button type='button' className='button' onClick={() => setShowForm(true)}>
|
<button type='button' className='button' onClick={() => openForm(draftKey)}>
|
||||||
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||||
</button>
|
</button>
|
||||||
]
|
]
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />
|
<PostFormTable key={draftKey} closeForm={closeForm} draftKey={draftKey} postCid={postCid} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { PostProps } from '../../views/post/post';
|
|||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import lowerCase from 'lodash/lowerCase';
|
import lowerCase from 'lodash/lowerCase';
|
||||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||||
|
import { getPageDraftKey } from '../../lib/utils/location-draft-utils';
|
||||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||||
@@ -186,7 +187,7 @@ const PostInfoAndMedia = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cid && postCid && communityAddress && openReplyModal) {
|
if (cid && postCid && communityAddress && openReplyModal) {
|
||||||
openReplyModal(cid, resolvedPost?.number, postCid, threadNumber, communityAddress);
|
openReplyModal(getPageDraftKey(location), cid, resolvedPost?.number, postCid, threadNumber, communityAddress);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const testState = vi.hoisted(() => ({
|
|||||||
offlineStatusLoading: false,
|
offlineStatusLoading: false,
|
||||||
offlineWarningVisible: false,
|
offlineWarningVisible: false,
|
||||||
openEmpty: false,
|
openEmpty: false,
|
||||||
|
locationDraftKey: '/mu/thread/post-1',
|
||||||
|
replyDraft: undefined as { content: string; flag?: string; link: string; options: string; spoiler: boolean } | undefined,
|
||||||
|
updateDraftMock: vi.fn(),
|
||||||
publishReplyMock: vi.fn(),
|
publishReplyMock: vi.fn(),
|
||||||
publishReplyError: null as string | null,
|
publishReplyError: null as string | null,
|
||||||
publishReplyStateMessage: null as string | null,
|
publishReplyStateMessage: null as string | null,
|
||||||
@@ -129,12 +132,32 @@ vi.mock('../../../stores/use-selected-text-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-reply-modal-store', () => ({
|
vi.mock('../../../stores/use-reply-modal-store', () => ({
|
||||||
default: <T,>(selector?: (state: { openEmpty: boolean; quoteInsertNumber?: number; quoteInsertRequestId: number; quoteInsertSelectedText: string }) => T) => {
|
default: <T,>(
|
||||||
|
selector?: (state: {
|
||||||
|
modals: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
draft?: typeof testState.replyDraft;
|
||||||
|
openEmpty: boolean;
|
||||||
|
quoteInsertNumber?: number;
|
||||||
|
quoteInsertRequestId: number;
|
||||||
|
quoteInsertSelectedText: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
updateDraft: typeof testState.updateDraftMock;
|
||||||
|
}) => T,
|
||||||
|
) => {
|
||||||
const state = {
|
const state = {
|
||||||
openEmpty: testState.openEmpty,
|
modals: {
|
||||||
quoteInsertNumber: testState.quoteInsertNumber,
|
[testState.locationDraftKey]: {
|
||||||
quoteInsertRequestId: testState.quoteInsertRequestId,
|
draft: testState.replyDraft,
|
||||||
quoteInsertSelectedText: testState.quoteInsertSelectedText,
|
openEmpty: testState.openEmpty,
|
||||||
|
quoteInsertNumber: testState.quoteInsertNumber,
|
||||||
|
quoteInsertRequestId: testState.quoteInsertRequestId,
|
||||||
|
quoteInsertSelectedText: testState.quoteInsertSelectedText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateDraft: testState.updateDraftMock,
|
||||||
};
|
};
|
||||||
return selector ? selector(state) : (state as T);
|
return selector ? selector(state) : (state as T);
|
||||||
},
|
},
|
||||||
@@ -324,6 +347,7 @@ const flushEffects = async (count = 4) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderReplyModal = async (initialEntry = '/mu/thread/post-1', communityAddress = 'music-posting.eth') => {
|
const renderReplyModal = async (initialEntry = '/mu/thread/post-1', communityAddress = 'music-posting.eth') => {
|
||||||
|
testState.locationDraftKey = initialEntry;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
createElement(
|
createElement(
|
||||||
@@ -331,6 +355,7 @@ const renderReplyModal = async (initialEntry = '/mu/thread/post-1', communityAdd
|
|||||||
{ initialEntries: [initialEntry] },
|
{ initialEntries: [initialEntry] },
|
||||||
createElement(ReplyModal, {
|
createElement(ReplyModal, {
|
||||||
closeModal: testState.closeModalMock,
|
closeModal: testState.closeModalMock,
|
||||||
|
locationDraftKey: initialEntry,
|
||||||
parentCid: 'parent-cid',
|
parentCid: 'parent-cid',
|
||||||
parentNumber: 42,
|
parentNumber: 42,
|
||||||
postCid: 'post-cid',
|
postCid: 'post-cid',
|
||||||
@@ -441,6 +466,9 @@ describe('ReplyModal', () => {
|
|||||||
testState.offlineStatusLoading = false;
|
testState.offlineStatusLoading = false;
|
||||||
testState.offlineWarningVisible = false;
|
testState.offlineWarningVisible = false;
|
||||||
testState.openEmpty = false;
|
testState.openEmpty = false;
|
||||||
|
testState.locationDraftKey = '/mu/thread/post-1';
|
||||||
|
testState.replyDraft = undefined;
|
||||||
|
testState.updateDraftMock.mockReset();
|
||||||
testState.publishReplyMock.mockReset();
|
testState.publishReplyMock.mockReset();
|
||||||
testState.publishReplyError = null;
|
testState.publishReplyError = null;
|
||||||
testState.publishReplyStateMessage = null;
|
testState.publishReplyStateMessage = null;
|
||||||
@@ -733,6 +761,63 @@ describe('ReplyModal', () => {
|
|||||||
expect(container.textContent).not.toContain('community_offline_info');
|
expect(container.textContent).not.toContain('community_offline_info');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores the draft fields saved for the current location', async () => {
|
||||||
|
testState.openEmpty = true;
|
||||||
|
testState.selectedText = '';
|
||||||
|
testState.replyDraft = {
|
||||||
|
content: 'saved reply draft',
|
||||||
|
flag: 'pol:AC',
|
||||||
|
link: 'https://example.com/saved.png',
|
||||||
|
options: 'nonoko',
|
||||||
|
spoiler: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
|
||||||
|
|
||||||
|
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('saved reply draft');
|
||||||
|
expect(container.querySelector<HTMLInputElement>('[aria-label="options"]')?.value).toBe('nonoko');
|
||||||
|
expect(container.querySelector<HTMLInputElement>('[aria-label="link"]')?.value).toBe('https://example.com/saved.png');
|
||||||
|
expect(container.querySelector<HTMLSelectElement>('[aria-label="flag"]')?.value).toBe('pol:AC');
|
||||||
|
expect(container.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(true);
|
||||||
|
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
|
||||||
|
content: 'saved reply draft',
|
||||||
|
link: 'https://example.com/saved.png',
|
||||||
|
spoiler: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the caret when a live draft update rerenders the modal', async () => {
|
||||||
|
testState.openEmpty = true;
|
||||||
|
testState.selectedText = '';
|
||||||
|
testState.replyDraft = {
|
||||||
|
content: 'saved reply draft',
|
||||||
|
link: '',
|
||||||
|
options: '',
|
||||||
|
spoiler: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
const textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
|
||||||
|
const nextContent = 'saved edited reply draft';
|
||||||
|
await act(async () => {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
|
||||||
|
descriptor?.set?.call(textarea, nextContent);
|
||||||
|
textarea.setSelectionRange(7, 7);
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
testState.replyDraft = {
|
||||||
|
...testState.replyDraft,
|
||||||
|
content: nextContent,
|
||||||
|
};
|
||||||
|
await rerenderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
expect(textarea.value).toBe(nextContent);
|
||||||
|
expect(textarea.selectionStart).toBe(7);
|
||||||
|
expect(textarea.selectionEnd).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
it('validates empty and invalid replies, then publishes once the payload is valid', async () => {
|
it('validates empty and invalid replies, then publishes once the payload is valid', async () => {
|
||||||
testState.openEmpty = true;
|
testState.openEmpty = true;
|
||||||
testState.selectedText = '';
|
testState.selectedText = '';
|
||||||
@@ -1409,6 +1494,25 @@ describe('ReplyModal', () => {
|
|||||||
expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n');
|
expect(textarea?.value).toBe('Existing line\n>>77\nQuoted line\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not replay a saved quote request when its location remounts', async () => {
|
||||||
|
testState.isMobile = true;
|
||||||
|
testState.openEmpty = true;
|
||||||
|
testState.replyDraft = {
|
||||||
|
content: 'Existing line\n>>77\nQuoted line\n',
|
||||||
|
link: '',
|
||||||
|
options: '',
|
||||||
|
spoiler: false,
|
||||||
|
};
|
||||||
|
testState.quoteInsertNumber = 77;
|
||||||
|
testState.quoteInsertRequestId = 1;
|
||||||
|
testState.quoteInsertSelectedText = 'Quoted line';
|
||||||
|
|
||||||
|
await renderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
|
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe('Existing line\n>>77\nQuoted line\n');
|
||||||
|
expect(testState.updateDraftMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses file-link placeholder defaults in all view and hides board-specific warnings or spoiler controls when disabled', async () => {
|
it('uses file-link placeholder defaults in all view and hides board-specific warnings or spoiler controls when disabled', async () => {
|
||||||
testState.directoryByAddress = {
|
testState.directoryByAddress = {
|
||||||
'music-posting.eth': {
|
'music-posting.eth': {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
|||||||
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
|
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
|
||||||
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import useSelectedTextStore from '../../stores/use-selected-text-store';
|
import useSelectedTextStore from '../../stores/use-selected-text-store';
|
||||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
import useReplyModalStore, { type ReplyModalDraft } from '../../stores/use-reply-modal-store';
|
||||||
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
||||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||||
import { useDirectories } from '../../hooks/use-directories';
|
import { useDirectories } from '../../hooks/use-directories';
|
||||||
@@ -108,6 +108,7 @@ const getInitialReplyModalPosition = (): ReplyModalPosition => {
|
|||||||
|
|
||||||
interface ReplyModalProps {
|
interface ReplyModalProps {
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
|
locationDraftKey: string;
|
||||||
showReplyModal: boolean;
|
showReplyModal: boolean;
|
||||||
parentCid: string;
|
parentCid: string;
|
||||||
parentNumber: number | null;
|
parentNumber: number | null;
|
||||||
@@ -117,7 +118,7 @@ interface ReplyModalProps {
|
|||||||
communityAddress: string;
|
communityAddress: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, communityAddress }: ReplyModalProps) => {
|
const ReplyModal = ({ closeModal, locationDraftKey, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, communityAddress }: ReplyModalProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -171,16 +172,21 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
const lastSelectionStartRef = useRef(0);
|
const lastSelectionStartRef = useRef(0);
|
||||||
const lastSelectionEndRef = useRef(0);
|
const lastSelectionEndRef = useRef(0);
|
||||||
const initializedReplyContentKeyRef = useRef('');
|
const initializedReplyContentKeyRef = useRef('');
|
||||||
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
|
|
||||||
const { selectedText } = useSelectedTextStore();
|
const { selectedText } = useSelectedTextStore();
|
||||||
const openEmpty = useReplyModalStore((state) => state.openEmpty);
|
const modalState = useReplyModalStore((state) => state.modals[locationDraftKey]);
|
||||||
const quoteInsertRequestId = useReplyModalStore((state) => state.quoteInsertRequestId);
|
const updateStoredDraft = useReplyModalStore((state) => state.updateDraft);
|
||||||
const quoteInsertNumber = useReplyModalStore((state) => state.quoteInsertNumber);
|
const updateDraft = useCallback((nextDraft: Partial<ReplyModalDraft>) => updateStoredDraft(locationDraftKey, nextDraft), [locationDraftKey, updateStoredDraft]);
|
||||||
const quoteInsertSelectedText = useReplyModalStore((state) => state.quoteInsertSelectedText);
|
const openEmpty = modalState?.openEmpty ?? false;
|
||||||
|
const quoteInsertRequestId = modalState?.quoteInsertRequestId ?? 0;
|
||||||
|
const quoteInsertNumber = modalState?.quoteInsertNumber ?? null;
|
||||||
|
const quoteInsertSelectedText = modalState?.quoteInsertSelectedText ?? null;
|
||||||
|
const draft = modalState?.draft;
|
||||||
|
const initialDraftRef = useRef(draft);
|
||||||
|
const lastProcessedQuoteInsertRequestIdRef = useRef(quoteInsertRequestId);
|
||||||
|
|
||||||
const [error, setError] = useState<string | PostOptionsValidationError | null>(null);
|
const [error, setError] = useState<string | PostOptionsValidationError | null>(null);
|
||||||
const [lengthError, setLengthError] = useState<string | null>(null);
|
const [lengthError, setLengthError] = useState<string | null>(null);
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState(draft?.link ?? '');
|
||||||
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
||||||
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
||||||
const [showTexPreview, setShowTexPreview] = useState(false);
|
const [showTexPreview, setShowTexPreview] = useState(false);
|
||||||
@@ -269,7 +275,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
|
||||||
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
await publishReply({
|
||||||
|
content: publishContent,
|
||||||
|
...getPublishLinkOptions(currentUrl, appliedYouTubeConversion),
|
||||||
|
...flagPublishOptions,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -398,7 +408,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialContent = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`;
|
const initialDraft = initialDraftRef.current;
|
||||||
|
const initialContent = initialDraft?.content ?? (openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`);
|
||||||
const initialContentKey = `${parentCid}:${openEmpty ? 'empty' : 'quoted'}:${initialContent}`;
|
const initialContentKey = `${parentCid}:${openEmpty ? 'empty' : 'quoted'}:${initialContent}`;
|
||||||
if (initializedReplyContentKeyRef.current === initialContentKey) {
|
if (initializedReplyContentKeyRef.current === initialContentKey) {
|
||||||
return;
|
return;
|
||||||
@@ -413,7 +424,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
const publishContent = getContentWithOptions(initialContent, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, {
|
const publishContent = getContentWithOptions(initialContent, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, {
|
||||||
includeFortune: false,
|
includeFortune: false,
|
||||||
});
|
});
|
||||||
setPublishReplyOptions({ content: publishContent });
|
setPublishReplyOptions({
|
||||||
|
content: publishContent,
|
||||||
|
...(initialDraft?.link ? { link: initialDraft.link } : {}),
|
||||||
|
...(initialDraft?.spoiler ? { spoiler: true } : {}),
|
||||||
|
});
|
||||||
checkContentLengthRef.current(publishContent, t, optionsRef.current?.value || '', postOptionsDirectoryCode);
|
checkContentLengthRef.current(publishContent, t, optionsRef.current?.value || '', postOptionsDirectoryCode);
|
||||||
|
|
||||||
const spellcheckTimeout = window.setTimeout(() => {
|
const spellcheckTimeout = window.setTimeout(() => {
|
||||||
@@ -450,6 +465,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number, options = optionsRef.current?.value || '') => {
|
const handleContentValueChange = (content: string, selectionStart?: number, selectionEnd?: number, options = optionsRef.current?.value || '') => {
|
||||||
|
updateDraft({ content });
|
||||||
if (isBbcodePreviewing) {
|
if (isBbcodePreviewing) {
|
||||||
setBbcodePreviewContent(content);
|
setBbcodePreviewContent(content);
|
||||||
}
|
}
|
||||||
@@ -464,6 +480,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
|
|
||||||
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const options = e.target.value;
|
const options = e.target.value;
|
||||||
|
updateDraft({ options });
|
||||||
handleContentValueChange(textRef.current?.value || '', undefined, undefined, options);
|
handleContentValueChange(textRef.current?.value || '', undefined, undefined, options);
|
||||||
setError((currentError) => (isPostOptionsValidationError(currentError) ? null : currentError));
|
setError((currentError) => (isPostOptionsValidationError(currentError) ? null : currentError));
|
||||||
checkPostOptionsRef.current(options, postOptionsDirectoryCode);
|
checkPostOptionsRef.current(options, postOptionsDirectoryCode);
|
||||||
@@ -486,6 +503,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
|
|
||||||
const setLinkValue = (nextUrl: string) => {
|
const setLinkValue = (nextUrl: string) => {
|
||||||
setUrl(nextUrl);
|
setUrl(nextUrl);
|
||||||
|
updateDraft({ link: nextUrl });
|
||||||
setPublishReplyOptions({ link: nextUrl });
|
setPublishReplyOptions({ link: nextUrl });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -571,9 +589,10 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, {
|
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, {
|
||||||
includeFortune: false,
|
includeFortune: false,
|
||||||
});
|
});
|
||||||
|
updateDraft({ content: nextValue });
|
||||||
setPublishReplyOptions({ content: publishContent });
|
setPublishReplyOptions({ content: publishContent });
|
||||||
checkContentLengthRef.current(publishContent, t, optionsRef.current?.value || '', postOptionsDirectoryCode);
|
checkContentLengthRef.current(publishContent, t, optionsRef.current?.value || '', postOptionsDirectoryCode);
|
||||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t, updateDraft]);
|
||||||
|
|
||||||
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
|
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
|
||||||
onUploadComplete: (uploadedUrl: string) => {
|
onUploadComplete: (uploadedUrl: string) => {
|
||||||
@@ -673,6 +692,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
autoCorrect='off'
|
autoCorrect='off'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
spellCheck='false'
|
spellCheck='false'
|
||||||
|
defaultValue={draft?.options}
|
||||||
onChange={handleOptionsChange}
|
onChange={handleOptionsChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -713,6 +733,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
aria-label={requireReplyLinkIsMedia ? t('link_to_file') : t('link')}
|
aria-label={requireReplyLinkIsMedia ? t('link_to_file') : t('link')}
|
||||||
placeholder={requireReplyLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
|
placeholder={requireReplyLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
|
||||||
disabled={isUploading || youtubeThumbnailConversionCountdown !== null || noReplyLinks}
|
disabled={isUploading || youtubeThumbnailConversionCountdown !== null || noReplyLinks}
|
||||||
|
defaultValue={draft?.link}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleLinkChange(e.target.value);
|
handleLinkChange(e.target.value);
|
||||||
}}
|
}}
|
||||||
@@ -734,7 +755,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
aria-label={t('flag')}
|
aria-label={t('flag')}
|
||||||
className={styles.flagSelector}
|
className={styles.flagSelector}
|
||||||
ref={flagRef}
|
ref={flagRef}
|
||||||
defaultValue={flagOptions[0]?.value}
|
defaultValue={draft?.flag ?? flagOptions[0]?.value}
|
||||||
|
onChange={(event) => updateDraft({ flag: event.target.value })}
|
||||||
>
|
>
|
||||||
{flagOptions.map((option) => (
|
{flagOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -761,7 +783,15 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
<span className={styles.spoilerButton}>
|
<span className={styles.spoilerButton}>
|
||||||
[
|
[
|
||||||
<label>
|
<label>
|
||||||
<input type='checkbox' aria-label={capitalize(t('spoiler'))} onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
aria-label={capitalize(t('spoiler'))}
|
||||||
|
defaultChecked={draft?.spoiler}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateDraft({ spoiler: e.target.checked });
|
||||||
|
setPublishReplyOptions({ spoiler: e.target.checked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{capitalize(t('spoiler'))}?
|
{capitalize(t('spoiler'))}?
|
||||||
</label>
|
</label>
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getLocationDraftKey, getPageDraftKey } from '../location-draft-utils';
|
||||||
|
|
||||||
|
describe('location draft utils', () => {
|
||||||
|
it('normalizes trailing slashes while preserving search and hash state', () => {
|
||||||
|
expect(getLocationDraftKey({ pathname: '/biz/', search: '?view=catalog', hash: '#post-1' })).toBe('/biz?view=catalog#post-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps settings overlays scoped to their underlying page', () => {
|
||||||
|
expect(getPageDraftKey({ pathname: '/biz/catalog/settings', search: '?s=bitcoin§ion=account-settings', hash: '' })).toBe('/biz/catalog?s=bitcoin');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
type DraftLocation = {
|
||||||
|
pathname: string;
|
||||||
|
search: string;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLocationDraftKey = ({ pathname, search, hash }: DraftLocation) => `${pathname.replace(/\/$/, '') || '/'}${search}${hash}`;
|
||||||
|
|
||||||
|
export const getPageDraftKey = ({ pathname, search, hash }: DraftLocation) => {
|
||||||
|
if (!pathname.endsWith('/settings')) {
|
||||||
|
return getLocationDraftKey({ pathname, search, hash });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings is an overlay, so discard its path and section while preserving the underlying page query.
|
||||||
|
const searchParams = new URLSearchParams(search);
|
||||||
|
searchParams.delete('section');
|
||||||
|
const pageSearch = searchParams.toString();
|
||||||
|
|
||||||
|
return getLocationDraftKey({
|
||||||
|
pathname: pathname.replace(/\/settings$/, ''),
|
||||||
|
search: pageSearch ? `?${pageSearch}` : '',
|
||||||
|
hash,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -12,17 +12,7 @@ import useThreadLiveUpdatesStore from '../use-thread-live-updates-store';
|
|||||||
|
|
||||||
const resetReplyModalStore = () => {
|
const resetReplyModalStore = () => {
|
||||||
useReplyModalStore.setState({
|
useReplyModalStore.setState({
|
||||||
showReplyModal: false,
|
modals: {},
|
||||||
openEmpty: false,
|
|
||||||
activeCid: null,
|
|
||||||
parentNumber: null,
|
|
||||||
threadNumber: null,
|
|
||||||
threadCid: null,
|
|
||||||
communityAddress: null,
|
|
||||||
scrollY: 0,
|
|
||||||
quoteInsertRequestId: 0,
|
|
||||||
quoteInsertNumber: null,
|
|
||||||
quoteInsertSelectedText: null,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,6 +272,7 @@ describe('interaction stores', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens reply modals with quoted selection and mobile scroll state', () => {
|
it('opens reply modals with quoted selection and mobile scroll state', () => {
|
||||||
|
const locationKey = '/mu/thread/thread-cid';
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 600,
|
value: 600,
|
||||||
@@ -294,10 +285,10 @@ describe('interaction stores', () => {
|
|||||||
});
|
});
|
||||||
vi.spyOn(document, 'getSelection').mockReturnValue({ toString: () => 'alpha\nbeta\n' } as Selection);
|
vi.spyOn(document, 'getSelection').mockReturnValue({ toString: () => 'alpha\nbeta\n' } as Selection);
|
||||||
|
|
||||||
useReplyModalStore.getState().openReplyModal('parent-cid', 12, 'thread-cid', 34, 'music.eth');
|
useReplyModalStore.getState().openReplyModal(locationKey, 'parent-cid', 12, 'thread-cid', 34, 'music.eth');
|
||||||
|
|
||||||
expect(useSelectedTextStore.getState().selectedText).toBe('>alpha\n>beta\n');
|
expect(useSelectedTextStore.getState().selectedText).toBe('>alpha\n>beta\n');
|
||||||
expect(useReplyModalStore.getState()).toMatchObject({
|
expect(useReplyModalStore.getState().modals[locationKey]).toMatchObject({
|
||||||
showReplyModal: true,
|
showReplyModal: true,
|
||||||
openEmpty: false,
|
openEmpty: false,
|
||||||
activeCid: 'thread-cid',
|
activeCid: 'thread-cid',
|
||||||
@@ -310,14 +301,16 @@ describe('interaction stores', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('inserts quote requests into an already-open reply modal and can reopen empty', () => {
|
it('inserts quote requests into an already-open reply modal and can reopen empty', () => {
|
||||||
useReplyModalStore.getState().openReplyModal('parent-cid', 12, 'thread-cid', 34, 'music.eth');
|
const locationKey = '/mu/thread/thread-cid';
|
||||||
|
const otherLocationKey = '/mu/thread/other-thread-cid';
|
||||||
|
useReplyModalStore.getState().openReplyModal(locationKey, 'parent-cid', 12, 'thread-cid', 34, 'music.eth');
|
||||||
vi.spyOn(document, 'getSelection').mockReturnValue({ toString: () => 'quoted text' } as Selection);
|
vi.spyOn(document, 'getSelection').mockReturnValue({ toString: () => 'quoted text' } as Selection);
|
||||||
|
|
||||||
useReplyModalStore.getState().openReplyModal('parent-cid-2', 77, 'thread-cid', 34, 'music.eth');
|
useReplyModalStore.getState().openReplyModal(locationKey, 'parent-cid-2', 77, 'thread-cid', 34, 'music.eth');
|
||||||
|
|
||||||
expect(useReplyModalStore.getState().quoteInsertRequestId).toBe(1);
|
expect(useReplyModalStore.getState().modals[locationKey].quoteInsertRequestId).toBe(1);
|
||||||
expect(useReplyModalStore.getState().quoteInsertNumber).toBe(77);
|
expect(useReplyModalStore.getState().modals[locationKey].quoteInsertNumber).toBe(77);
|
||||||
expect(useReplyModalStore.getState().quoteInsertSelectedText).toBe('>quoted text');
|
expect(useReplyModalStore.getState().modals[locationKey].quoteInsertSelectedText).toBe('>quoted text');
|
||||||
|
|
||||||
useSelectedTextStore.getState().setSelectedText('stale quote');
|
useSelectedTextStore.getState().setSelectedText('stale quote');
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
@@ -331,32 +324,36 @@ describe('interaction stores', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useReplyModalStore.getState().openReplyModalEmpty('thread-cid', 34, 'music.eth');
|
useReplyModalStore.getState().updateDraft(locationKey, { content: 'first route draft', link: 'https://example.com/first.png' });
|
||||||
|
useReplyModalStore.getState().openReplyModalEmpty(otherLocationKey, 'other-thread-cid', 35, 'music.eth');
|
||||||
|
|
||||||
expect(useSelectedTextStore.getState().selectedText).toBe('');
|
expect(useSelectedTextStore.getState().selectedText).toBe('');
|
||||||
expect(useReplyModalStore.getState()).toMatchObject({
|
expect(useReplyModalStore.getState().modals[otherLocationKey]).toMatchObject({
|
||||||
showReplyModal: true,
|
showReplyModal: true,
|
||||||
openEmpty: true,
|
openEmpty: true,
|
||||||
activeCid: 'thread-cid',
|
activeCid: 'other-thread-cid',
|
||||||
threadNumber: 34,
|
threadNumber: 35,
|
||||||
threadCid: 'thread-cid',
|
threadCid: 'other-thread-cid',
|
||||||
communityAddress: 'music.eth',
|
communityAddress: 'music.eth',
|
||||||
scrollY: 32,
|
scrollY: 32,
|
||||||
quoteInsertNumber: null,
|
quoteInsertNumber: null,
|
||||||
quoteInsertSelectedText: null,
|
quoteInsertSelectedText: null,
|
||||||
});
|
});
|
||||||
|
expect(useReplyModalStore.getState().modals[otherLocationKey].draft).toEqual({
|
||||||
|
content: '',
|
||||||
|
link: '',
|
||||||
|
options: '',
|
||||||
|
spoiler: false,
|
||||||
|
});
|
||||||
|
expect(useReplyModalStore.getState().modals[locationKey].draft).toMatchObject({
|
||||||
|
content: 'first route draft',
|
||||||
|
link: 'https://example.com/first.png',
|
||||||
|
});
|
||||||
|
|
||||||
useSelectedTextStore.getState().setSelectedText('cleanup');
|
useSelectedTextStore.getState().setSelectedText('cleanup');
|
||||||
useReplyModalStore.getState().closeModal();
|
useReplyModalStore.getState().closeModal(otherLocationKey);
|
||||||
expect(useSelectedTextStore.getState().selectedText).toBe('');
|
expect(useSelectedTextStore.getState().selectedText).toBe('');
|
||||||
expect(useReplyModalStore.getState()).toMatchObject({
|
expect(useReplyModalStore.getState().modals[otherLocationKey]).toBeUndefined();
|
||||||
showReplyModal: false,
|
expect(useReplyModalStore.getState().modals[locationKey].showReplyModal).toBe(true);
|
||||||
openEmpty: false,
|
|
||||||
activeCid: null,
|
|
||||||
parentNumber: null,
|
|
||||||
threadNumber: null,
|
|
||||||
quoteInsertNumber: null,
|
|
||||||
quoteInsertSelectedText: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type PostFormDraft = {
|
||||||
|
communityAddress?: string;
|
||||||
|
content: string;
|
||||||
|
flag?: string;
|
||||||
|
flashTag: string;
|
||||||
|
link: string;
|
||||||
|
options: string;
|
||||||
|
spoiler: boolean;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostFormState = {
|
||||||
|
draft: PostFormDraft;
|
||||||
|
isOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostFormDraftsState = {
|
||||||
|
forms: Record<string, PostFormState>;
|
||||||
|
clearForm: (locationKey: string) => void;
|
||||||
|
openForm: (locationKey: string) => void;
|
||||||
|
updateDraft: (locationKey: string, draft: Partial<PostFormDraft>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: PostFormDraft = {
|
||||||
|
content: '',
|
||||||
|
flashTag: '',
|
||||||
|
link: '',
|
||||||
|
options: '',
|
||||||
|
spoiler: false,
|
||||||
|
title: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_POST_FORM_STATE: PostFormState = {
|
||||||
|
draft: EMPTY_DRAFT,
|
||||||
|
isOpen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePostFormDraftsStore = create<PostFormDraftsState>((set) => ({
|
||||||
|
forms: {},
|
||||||
|
clearForm: (locationKey) =>
|
||||||
|
set((state) => {
|
||||||
|
const { [locationKey]: _clearedForm, ...forms } = state.forms;
|
||||||
|
return { forms };
|
||||||
|
}),
|
||||||
|
openForm: (locationKey) =>
|
||||||
|
set((state) => ({
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
[locationKey]: {
|
||||||
|
...(state.forms[locationKey] ?? EMPTY_POST_FORM_STATE),
|
||||||
|
isOpen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
updateDraft: (locationKey, draft) =>
|
||||||
|
set((state) => {
|
||||||
|
const current = state.forms[locationKey] ?? EMPTY_POST_FORM_STATE;
|
||||||
|
return {
|
||||||
|
forms: {
|
||||||
|
...state.forms,
|
||||||
|
[locationKey]: {
|
||||||
|
...current,
|
||||||
|
draft: {
|
||||||
|
...current.draft,
|
||||||
|
...draft,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default usePostFormDraftsStore;
|
||||||
@@ -1,23 +1,44 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import useSelectedTextStore from './use-selected-text-store';
|
import useSelectedTextStore from './use-selected-text-store';
|
||||||
|
|
||||||
interface ReplyModalState {
|
export type ReplyModalDraft = {
|
||||||
|
content: string;
|
||||||
|
flag?: string;
|
||||||
|
link: string;
|
||||||
|
options: string;
|
||||||
|
spoiler: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplyModalLocationState = {
|
||||||
showReplyModal: boolean;
|
showReplyModal: boolean;
|
||||||
/** True when opened via "Post a Reply" footer button — textarea should be empty, no quote. */
|
/** True when opened via "Post a Reply" footer button — textarea should be empty, no quote. */
|
||||||
openEmpty: boolean;
|
openEmpty: boolean;
|
||||||
activeCid: string | null;
|
activeCid: string;
|
||||||
parentNumber: number | null;
|
parentNumber: number | null;
|
||||||
threadNumber: number | null;
|
threadNumber: number | null;
|
||||||
threadCid: string | null;
|
threadCid: string;
|
||||||
communityAddress: string | null;
|
communityAddress: string;
|
||||||
scrollY: number;
|
scrollY: number;
|
||||||
quoteInsertRequestId: number;
|
quoteInsertRequestId: number;
|
||||||
quoteInsertNumber: number | null;
|
quoteInsertNumber: number | null;
|
||||||
quoteInsertSelectedText: string | null;
|
quoteInsertSelectedText: string | null;
|
||||||
closeModal: () => void;
|
draft: ReplyModalDraft;
|
||||||
openReplyModal: (parentCid: string, parentNumber: number | undefined, postCid: string, threadNumber: number | undefined, communityAddress: string) => void;
|
};
|
||||||
|
|
||||||
|
interface ReplyModalState {
|
||||||
|
modals: Record<string, ReplyModalLocationState>;
|
||||||
|
closeModal: (locationKey: string) => void;
|
||||||
|
openReplyModal: (
|
||||||
|
locationKey: string,
|
||||||
|
parentCid: string,
|
||||||
|
parentNumber: number | undefined,
|
||||||
|
postCid: string,
|
||||||
|
threadNumber: number | undefined,
|
||||||
|
communityAddress: string,
|
||||||
|
) => void;
|
||||||
/** Open reply modal with empty textarea, no prefilled quote. Use for "Post a Reply" footer button. */
|
/** Open reply modal with empty textarea, no prefilled quote. Use for "Post a Reply" footer button. */
|
||||||
openReplyModalEmpty: (postCid: string, threadNumber: number | undefined, communityAddress: string) => void;
|
openReplyModalEmpty: (locationKey: string, postCid: string, threadNumber: number | undefined, communityAddress: string) => void;
|
||||||
|
updateDraft: (locationKey: string, draft: Partial<ReplyModalDraft>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getQuotedSelection = () => {
|
const getQuotedSelection = () => {
|
||||||
@@ -35,83 +56,119 @@ const getQuotedSelection = () => {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
const useReplyModalStore = create<ReplyModalState>((set, get) => ({
|
const getScrollY = () => (window.innerWidth <= 768 ? window.scrollY : 0);
|
||||||
showReplyModal: false,
|
|
||||||
openEmpty: false,
|
|
||||||
activeCid: null,
|
|
||||||
parentNumber: null,
|
|
||||||
threadNumber: null,
|
|
||||||
threadCid: null,
|
|
||||||
communityAddress: null,
|
|
||||||
scrollY: 0,
|
|
||||||
quoteInsertRequestId: 0,
|
|
||||||
quoteInsertNumber: null,
|
|
||||||
quoteInsertSelectedText: null,
|
|
||||||
|
|
||||||
closeModal: () => {
|
const EMPTY_REPLY_MODAL_DRAFT: ReplyModalDraft = {
|
||||||
// Reset selected text if you're using that store
|
content: '',
|
||||||
|
link: '',
|
||||||
|
options: '',
|
||||||
|
spoiler: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const useReplyModalStore = create<ReplyModalState>((set, get) => ({
|
||||||
|
modals: {},
|
||||||
|
|
||||||
|
closeModal: (locationKey) => {
|
||||||
useSelectedTextStore.getState().resetSelectedText();
|
useSelectedTextStore.getState().resetSelectedText();
|
||||||
set({
|
set((state) => {
|
||||||
showReplyModal: false,
|
const { [locationKey]: _closedModal, ...modals } = state.modals;
|
||||||
openEmpty: false,
|
return { modals };
|
||||||
activeCid: null,
|
|
||||||
parentNumber: null,
|
|
||||||
threadNumber: null,
|
|
||||||
quoteInsertNumber: null,
|
|
||||||
quoteInsertSelectedText: null,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
openReplyModal: (parentCid, parentNumber, postCid, threadNumber, communityAddress) => {
|
openReplyModal: (locationKey, parentCid, parentNumber, postCid, threadNumber, communityAddress) => {
|
||||||
const quotedSelection = getQuotedSelection();
|
const quotedSelection = getQuotedSelection();
|
||||||
|
const currentModal = get().modals[locationKey];
|
||||||
|
|
||||||
// If the reply modal is already open, insert this quote in the current textarea at caret.
|
// If the reply modal is already open on this location, insert this quote in its current textarea at the caret.
|
||||||
if (get().showReplyModal) {
|
if (currentModal?.showReplyModal) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
quoteInsertRequestId: state.quoteInsertRequestId + 1,
|
modals: {
|
||||||
quoteInsertNumber: parentNumber ?? null,
|
...state.modals,
|
||||||
quoteInsertSelectedText: quotedSelection || null,
|
[locationKey]: {
|
||||||
|
...currentModal,
|
||||||
|
quoteInsertRequestId: currentModal.quoteInsertRequestId + 1,
|
||||||
|
quoteInsertNumber: parentNumber ?? null,
|
||||||
|
quoteInsertSelectedText: quotedSelection || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quotedSelection) {
|
const selectedText = quotedSelection ? `${quotedSelection}\n` : '';
|
||||||
useSelectedTextStore.getState().setSelectedText(`${quotedSelection}\n`);
|
if (selectedText) {
|
||||||
|
useSelectedTextStore.getState().setSelectedText(selectedText);
|
||||||
|
} else {
|
||||||
|
useSelectedTextStore.getState().resetSelectedText();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle mobile scrollY
|
set((state) => ({
|
||||||
const isMobile = window.innerWidth <= 768; // Simple check, adjust as needed
|
modals: {
|
||||||
const scrollY = isMobile ? window.scrollY : 0;
|
...state.modals,
|
||||||
|
[locationKey]: {
|
||||||
set({
|
showReplyModal: true,
|
||||||
openEmpty: false,
|
openEmpty: false,
|
||||||
activeCid: postCid,
|
activeCid: postCid,
|
||||||
parentNumber: parentNumber ?? null,
|
parentNumber: parentNumber ?? null,
|
||||||
threadNumber: threadNumber ?? null,
|
threadNumber: threadNumber ?? null,
|
||||||
threadCid: postCid,
|
threadCid: postCid,
|
||||||
showReplyModal: true,
|
communityAddress,
|
||||||
communityAddress,
|
scrollY: getScrollY(),
|
||||||
scrollY,
|
quoteInsertRequestId: 0,
|
||||||
});
|
quoteInsertNumber: null,
|
||||||
|
quoteInsertSelectedText: null,
|
||||||
|
draft: {
|
||||||
|
...EMPTY_REPLY_MODAL_DRAFT,
|
||||||
|
content: `>>${parentNumber ?? '?'}\n${selectedText}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
openReplyModalEmpty: (postCid, threadNumber, communityAddress) => {
|
openReplyModalEmpty: (locationKey, postCid, threadNumber, communityAddress) => {
|
||||||
useSelectedTextStore.getState().resetSelectedText();
|
useSelectedTextStore.getState().resetSelectedText();
|
||||||
const isMobile = window.innerWidth <= 768;
|
set((state) => ({
|
||||||
const scrollY = isMobile ? window.scrollY : 0;
|
modals: {
|
||||||
set({
|
...state.modals,
|
||||||
openEmpty: true,
|
[locationKey]: {
|
||||||
activeCid: postCid,
|
showReplyModal: true,
|
||||||
parentNumber: null,
|
openEmpty: true,
|
||||||
threadNumber: threadNumber ?? null,
|
activeCid: postCid,
|
||||||
threadCid: postCid,
|
parentNumber: null,
|
||||||
showReplyModal: true,
|
threadNumber: threadNumber ?? null,
|
||||||
communityAddress,
|
threadCid: postCid,
|
||||||
scrollY,
|
communityAddress,
|
||||||
quoteInsertNumber: null,
|
scrollY: getScrollY(),
|
||||||
quoteInsertSelectedText: null,
|
quoteInsertRequestId: 0,
|
||||||
});
|
quoteInsertNumber: null,
|
||||||
|
quoteInsertSelectedText: null,
|
||||||
|
draft: { ...EMPTY_REPLY_MODAL_DRAFT },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateDraft: (locationKey, draft) =>
|
||||||
|
set((state) => {
|
||||||
|
const modal = state.modals[locationKey];
|
||||||
|
if (!modal) return state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
modals: {
|
||||||
|
...state.modals,
|
||||||
|
[locationKey]: {
|
||||||
|
...modal,
|
||||||
|
draft: {
|
||||||
|
...EMPTY_REPLY_MODAL_DRAFT,
|
||||||
|
...modal.draft,
|
||||||
|
...draft,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useReplyModalStore;
|
export default useReplyModalStore;
|
||||||
|
|||||||
Reference in New Issue
Block a user