mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix: share board offline state between post form and reply modal
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import useSubplebbitsStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits';
|
||||||
|
import { normalizeBoardAddress, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||||
|
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||||
|
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||||
|
|
||||||
|
const BOARD_ALIAS_SUFFIXES = ['.bso', '.eth'] as const;
|
||||||
|
|
||||||
|
const getBoardAddressCandidates = (addresses: Array<string | undefined>) => {
|
||||||
|
const uniqueCandidates = new Set<string>();
|
||||||
|
|
||||||
|
const addCandidate = (candidate: string | undefined) => {
|
||||||
|
if (candidate) {
|
||||||
|
uniqueCandidates.add(candidate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addresses.forEach((address) => {
|
||||||
|
if (!address) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidate(address);
|
||||||
|
|
||||||
|
const normalizedAddress = normalizeBoardAddress(address);
|
||||||
|
addCandidate(normalizedAddress);
|
||||||
|
BOARD_ALIAS_SUFFIXES.forEach((suffix) => addCandidate(`${normalizedAddress}${suffix}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(uniqueCandidates);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BoardOfflineAlertProps {
|
||||||
|
className: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
subplebbitAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoardOfflineAlert = ({ className, hidden = false, subplebbitAddress }: BoardOfflineAlertProps) => {
|
||||||
|
const resolvedSubplebbitAddress = useResolvedSubplebbitAddress();
|
||||||
|
const directoryEntry = useDirectoryByAddress(resolvedSubplebbitAddress || subplebbitAddress);
|
||||||
|
const addressCandidates = useMemo(
|
||||||
|
() => getBoardAddressCandidates([resolvedSubplebbitAddress, directoryEntry?.address, subplebbitAddress]),
|
||||||
|
[directoryEntry?.address, resolvedSubplebbitAddress, subplebbitAddress],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Probe common aliases first so loading/offline state stays consistent across route and post payload address formats.
|
||||||
|
const subplebbit = useSubplebbitsStore((state) => {
|
||||||
|
for (const candidate of addressCandidates) {
|
||||||
|
const matchedSubplebbit = state.subplebbits[candidate];
|
||||||
|
if (matchedSubplebbit) {
|
||||||
|
return matchedSubplebbit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
|
||||||
|
|
||||||
|
if (hidden || (!isOffline && !isOnlineStatusLoading)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={className}>{offlineTitle}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoardOfflineAlert;
|
||||||
@@ -82,6 +82,7 @@ vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({
|
|||||||
vi.mock('../../../hooks/use-directories', () => ({
|
vi.mock('../../../hooks/use-directories', () => ({
|
||||||
useDirectories: () => testState.directories,
|
useDirectories: () => testState.directories,
|
||||||
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
|
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
|
||||||
|
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocialhq/bitsocial-react-hooks';
|
import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocialhq/bitsocial-react-hooks';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
import getShortAddress from '../../lib/get-short-address';
|
||||||
import useSubplebbitsStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits';
|
|
||||||
import useSubplebbitsPagesStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
import useSubplebbitsPagesStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
||||||
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
|
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||||
import { isValidURL } from '../../lib/utils/url-utils';
|
import { isValidURL } from '../../lib/utils/url-utils';
|
||||||
@@ -13,29 +12,16 @@ import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directori
|
|||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||||
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
|
||||||
import usePublishPost from '../../hooks/use-publish-post';
|
import usePublishPost from '../../hooks/use-publish-post';
|
||||||
import usePublishReply from '../../hooks/use-publish-reply';
|
import usePublishReply from '../../hooks/use-publish-reply';
|
||||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||||
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 BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
||||||
import styles from './post-form.module.css';
|
import styles from './post-form.module.css';
|
||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
// Separate component for offline alert to isolate rerenders from updatingState
|
|
||||||
// Only this component will rerender when updatingState changes, not the whole PostForm
|
|
||||||
const OfflineAlert = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => {
|
|
||||||
const subplebbit = useSubplebbitsStore((state) => (subplebbitAddress ? state.subplebbits[subplebbitAddress] : undefined));
|
|
||||||
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
|
|
||||||
|
|
||||||
if (!isOffline && !isOnlineStatusLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={styles.offlineBoard}>{offlineTitle}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LinkTypePreviewer = ({ link }: { link: string }) => {
|
export const LinkTypePreviewer = ({ link }: { link: string }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mediaInfo = getLinkMediaInfo(link);
|
const mediaInfo = getLinkMediaInfo(link);
|
||||||
@@ -539,7 +525,7 @@ const PostForm = () => {
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.postFormMobile}>
|
<div className={styles.postFormMobile}>
|
||||||
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
{shouldShowOfflineAlert && <BoardOfflineAlert className={styles.offlineBoard} subplebbitAddress={subplebbitAddress} />}
|
||||||
{isInModQueueView ? (
|
{isInModQueueView ? (
|
||||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||||
) : isThreadClosed ? (
|
) : isThreadClosed ? (
|
||||||
@@ -563,7 +549,7 @@ const PostForm = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.postFormDesktop}>
|
<div className={styles.postFormDesktop}>
|
||||||
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
{shouldShowOfflineAlert && <BoardOfflineAlert className={styles.offlineBoard} subplebbitAddress={subplebbitAddress} />}
|
||||||
{isInModQueueView ? (
|
{isInModQueueView ? (
|
||||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||||
) : isThreadClosed ? (
|
) : isThreadClosed ? (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
isMobile: false,
|
isMobile: false,
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
offlineTitle: '' as string | false,
|
offlineTitle: '' as string | false,
|
||||||
|
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
|
||||||
offlineStatusLoading: false,
|
offlineStatusLoading: false,
|
||||||
offlineWarningVisible: false,
|
offlineWarningVisible: false,
|
||||||
openEmpty: false,
|
openEmpty: false,
|
||||||
@@ -30,6 +31,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
quoteInsertSelectedText: '',
|
quoteInsertSelectedText: '',
|
||||||
replyIndex: undefined as number | undefined,
|
replyIndex: undefined as number | undefined,
|
||||||
resetPublishReplyOptionsMock: vi.fn(),
|
resetPublishReplyOptionsMock: vi.fn(),
|
||||||
|
resolvedSubplebbitAddress: undefined as string | undefined,
|
||||||
selectedText: 'selected text',
|
selectedText: 'selected text',
|
||||||
setAccountMock: vi.fn(),
|
setAccountMock: vi.fn(),
|
||||||
setPublishReplyOptionsMock: vi.fn(),
|
setPublishReplyOptionsMock: vi.fn(),
|
||||||
@@ -77,11 +79,12 @@ vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({
|
vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({
|
||||||
default: () => ({
|
default: (subplebbit?: { address?: string }) =>
|
||||||
isOffline: testState.offlineWarningVisible,
|
(subplebbit?.address ? testState.offlineStates[subplebbit.address] : undefined) || {
|
||||||
isOnlineStatusLoading: testState.offlineStatusLoading,
|
isOffline: testState.offlineWarningVisible,
|
||||||
offlineTitle: testState.offlineTitle,
|
isOnlineStatusLoading: testState.offlineStatusLoading,
|
||||||
}),
|
offlineTitle: testState.offlineTitle,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-selected-text-store', () => ({
|
vi.mock('../../../stores/use-selected-text-store', () => ({
|
||||||
@@ -116,6 +119,11 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../hooks/use-directories', () => ({
|
vi.mock('../../../hooks/use-directories', () => ({
|
||||||
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
|
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
|
||||||
|
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||||
|
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-publish-reply', () => ({
|
vi.mock('../../../hooks/use-publish-reply', () => ({
|
||||||
@@ -185,7 +193,7 @@ const flushEffects = async (count = 4) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderReplyModal = async (initialEntry = '/mu/thread/post-1') => {
|
const renderReplyModal = async (initialEntry = '/mu/thread/post-1', subplebbitAddress = 'music-posting.eth') => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
createElement(
|
createElement(
|
||||||
@@ -198,7 +206,7 @@ const renderReplyModal = async (initialEntry = '/mu/thread/post-1') => {
|
|||||||
postCid: 'post-cid',
|
postCid: 'post-cid',
|
||||||
scrollY: 120,
|
scrollY: 120,
|
||||||
showReplyModal: true,
|
showReplyModal: true,
|
||||||
subplebbitAddress: 'music-posting.eth',
|
subplebbitAddress,
|
||||||
threadNumber: 42,
|
threadNumber: 42,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -243,6 +251,7 @@ describe('ReplyModal', () => {
|
|||||||
testState.isMobile = false;
|
testState.isMobile = false;
|
||||||
testState.isUploading = false;
|
testState.isUploading = false;
|
||||||
testState.offlineTitle = '';
|
testState.offlineTitle = '';
|
||||||
|
testState.offlineStates = {};
|
||||||
testState.offlineStatusLoading = false;
|
testState.offlineStatusLoading = false;
|
||||||
testState.offlineWarningVisible = false;
|
testState.offlineWarningVisible = false;
|
||||||
testState.openEmpty = false;
|
testState.openEmpty = false;
|
||||||
@@ -252,6 +261,7 @@ describe('ReplyModal', () => {
|
|||||||
testState.quoteInsertSelectedText = '';
|
testState.quoteInsertSelectedText = '';
|
||||||
testState.replyIndex = undefined;
|
testState.replyIndex = undefined;
|
||||||
testState.resetPublishReplyOptionsMock.mockReset();
|
testState.resetPublishReplyOptionsMock.mockReset();
|
||||||
|
testState.resolvedSubplebbitAddress = undefined;
|
||||||
testState.selectedText = 'selected text';
|
testState.selectedText = 'selected text';
|
||||||
testState.setAccountMock.mockReset();
|
testState.setAccountMock.mockReset();
|
||||||
testState.setPublishReplyOptionsMock.mockReset();
|
testState.setPublishReplyOptionsMock.mockReset();
|
||||||
@@ -302,6 +312,29 @@ describe('ReplyModal', () => {
|
|||||||
expect(container.textContent).not.toContain('subplebbit_offline_info');
|
expect(container.textContent).not.toContain('subplebbit_offline_info');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers the resolved board entry when the modal prop address uses a different alias', async () => {
|
||||||
|
testState.offlineTitle = 'subplebbit_offline_info';
|
||||||
|
testState.offlineWarningVisible = true;
|
||||||
|
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||||
|
testState.subplebbits = {
|
||||||
|
'music-posting.eth': {
|
||||||
|
address: 'music-posting.eth',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testState.offlineStates = {
|
||||||
|
'music-posting.eth': {
|
||||||
|
isOffline: false,
|
||||||
|
isOnlineStatusLoading: false,
|
||||||
|
offlineTitle: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderReplyModal('/mu/thread/post-1', 'music-posting.bso');
|
||||||
|
|
||||||
|
expect(container.querySelector('[class*="offlineBoard"]')).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain('subplebbit_offline_info');
|
||||||
|
});
|
||||||
|
|
||||||
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 = '';
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { setAccount, useAccount } from '@bitsocialhq/bitsocial-react-hooks';
|
import { setAccount, useAccount } from '@bitsocialhq/bitsocial-react-hooks';
|
||||||
import useSubplebbitsStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits';
|
|
||||||
import { isValidURL } from '../../lib/utils/url-utils';
|
import { isValidURL } from '../../lib/utils/url-utils';
|
||||||
import { isAllView, 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 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 { useDirectoryByAddress } from '../../hooks/use-directories';
|
import { useDirectoryByAddress } from '../../hooks/use-directories';
|
||||||
import usePublishReply from '../../hooks/use-publish-reply';
|
import usePublishReply from '../../hooks/use-publish-reply';
|
||||||
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||||
|
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
||||||
import styles from './reply-modal.module.css';
|
import styles from './reply-modal.module.css';
|
||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
@@ -31,22 +30,12 @@ interface ReplyModalProps {
|
|||||||
subplebbitAddress: string;
|
subplebbitAddress: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReplyModalOfflineAlert = ({ hidden, subplebbitAddress }: { hidden: boolean; subplebbitAddress: string }) => {
|
|
||||||
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
|
|
||||||
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
|
|
||||||
|
|
||||||
if (hidden || (!isOffline && !isOnlineStatusLoading)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={styles.offlineBoard}>{offlineTitle}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => {
|
const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const isInAllView = isAllView(location.pathname);
|
const isInAllView = isAllView(location.pathname);
|
||||||
|
const isInModView = isModView(location.pathname);
|
||||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||||
const directoryEntry = useDirectoryByAddress(subplebbitAddress);
|
const directoryEntry = useDirectoryByAddress(subplebbitAddress);
|
||||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
@@ -376,7 +365,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{lengthError ? <div className={styles.error}>{lengthError}</div> : error && <div className={styles.error}>{error}</div>}
|
{lengthError ? <div className={styles.error}>{lengthError}</div> : error && <div className={styles.error}>{error}</div>}
|
||||||
<ReplyModalOfflineAlert hidden={isInAllView || isInSubscriptionsView} subplebbitAddress={subplebbitAddress} />
|
<BoardOfflineAlert className={styles.offlineBoard} hidden={isInAllView || isInSubscriptionsView || isInModView} subplebbitAddress={subplebbitAddress} />
|
||||||
</div>
|
</div>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user