Files
5chan/src/components/reply-modal/reply-modal.tsx
T

337 lines
11 KiB
TypeScript
Raw Normal View History

2024-08-06 21:51:45 +02:00
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
2024-10-15 13:10:47 +02:00
import { Trans, useTranslation } from 'react-i18next';
2024-04-28 20:08:41 +02:00
import Draggable from 'react-draggable';
import { setAccount, useAccount, useComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
2024-04-28 20:08:41 +02:00
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
import { formatMarkdown } from '../../lib/utils/post-utils';
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
2024-04-28 20:08:41 +02:00
import { isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
import useSelectedTextStore from '../../stores/use-selected-text-store';
2024-08-06 21:57:28 +02:00
import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
2024-04-28 20:08:41 +02:00
import styles from './reply-modal.module.css';
import { LinkTypePreviewer } from '../post-form';
2024-04-28 20:08:41 +02:00
import _ from 'lodash';
import useAnonMode from '../../hooks/use-anon-mode';
import FileUploader from '../../plugins/file-uploader';
import { Capacitor } from '@capacitor/core';
const isAndroid = Capacitor.getPlatform() === 'android';
2024-04-28 20:08:41 +02:00
interface ReplyModalProps {
closeModal: () => void;
2024-08-09 12:08:22 +02:00
showReplyModal: boolean;
2024-04-28 20:08:41 +02:00
parentCid: string;
postCid: string;
scrollY: number;
subplebbitAddress: string;
2024-04-28 20:08:41 +02:00
}
const ReplyModal = ({ closeModal, showReplyModal, parentCid, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => {
2024-04-28 20:08:41 +02:00
const { t } = useTranslation();
const { setPublishReplyOptions, publishReply, resetPublishReplyOptions, replyIndex } = usePublishReply({
cid: parentCid,
subplebbitAddress,
postCid,
});
2024-04-28 20:08:41 +02:00
const account = useAccount();
const { displayName } = account?.author || {};
const [url, setUrl] = useState('');
const textRef = useRef<HTMLTextAreaElement | null>(null);
2024-04-28 20:08:41 +02:00
const urlRef = useRef<HTMLInputElement>(null);
const { selectedText } = useSelectedTextStore();
2024-04-28 20:08:41 +02:00
const { anonMode, getNewSigner, getExistingSigner } = useAnonMode(postCid);
const comment = useComment({ commentCid: postCid });
const address = comment?.author?.address;
2024-08-06 21:51:45 +02:00
const getAnonAddressForReply = useCallback(async () => {
const existingSigner = await getExistingSigner(address);
if (existingSigner) {
setPublishReplyOptions({
signer: existingSigner,
author: {
address: existingSigner.address,
displayName: displayName || undefined,
},
});
} else {
const newSigner = await getNewSigner();
if (newSigner) {
2024-08-08 17:48:45 +02:00
setPublishReplyOptions({
signer: newSigner,
2024-08-08 17:48:45 +02:00
author: {
address: newSigner.address,
2024-08-25 18:58:38 +02:00
displayName: displayName || undefined,
2024-08-08 17:48:45 +02:00
},
});
}
}
}, [address, getExistingSigner, getNewSigner, setPublishReplyOptions, displayName]);
2024-11-04 17:55:51 +01:00
const [error, setError] = useState<string | null>(null);
2024-08-21 11:11:19 +02:00
const onPublishReply = () => {
const currentContent = textRef.current?.value.slice(contentPrefix.length).trim() || '';
const currentUrl = urlRef.current?.value.trim() || '';
2024-04-28 20:08:41 +02:00
if (!currentContent && !currentUrl) {
2024-11-04 17:55:51 +01:00
setError(t('empty_comment_alert'));
2024-04-28 20:08:41 +02:00
return;
}
if (currentUrl && !isValidURL(currentUrl)) {
2024-11-04 17:55:51 +01:00
setError(t('invalid_url_alert'));
2024-04-28 20:08:41 +02:00
return;
}
2024-11-04 17:55:51 +01:00
setError(null);
2024-08-06 21:51:45 +02:00
publishReply();
2024-04-28 20:08:41 +02:00
};
2024-08-25 18:58:38 +02:00
useEffect(() => {
if (anonMode) {
setPublishReplyOptions({
signer: undefined,
author: {
address: undefined,
displayName: displayName || undefined,
},
});
2024-08-25 18:58:38 +02:00
getAnonAddressForReply();
} else {
setPublishReplyOptions({
signer: undefined,
author: {
...account?.author,
displayName: displayName || account?.author?.displayName,
},
});
2024-08-25 18:58:38 +02:00
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anonMode]);
2024-08-25 18:58:38 +02:00
2024-08-21 11:11:19 +02:00
useEffect(() => {
if (typeof replyIndex === 'number') {
resetPublishReplyOptions();
closeModal();
}
}, [replyIndex, resetPublishReplyOptions, closeModal]);
const nodeRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
2024-04-28 20:08:41 +02:00
useEffect(() => {
if (nodeRef.current && isMobile) {
const viewportHeight = window.innerHeight;
const modalHeight = 150;
const centeredPosition = scrollY + viewportHeight / 2 - modalHeight / 2;
nodeRef.current.style.top = `${centeredPosition}px`;
}
}, [isMobile, scrollY]);
const parentCidRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (parentCidRef.current && parentCidRef.current) {
const cidWidth = parentCidRef.current.offsetWidth;
parentCidRef.current.style.width = `${cidWidth}px`;
}
}, [parentCid]);
const location = useLocation();
2024-10-29 17:40:09 +01:00
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
const subplebbit = useSubplebbit({ subplebbitAddress });
const { updatedAt } = subplebbit || {};
const isBoardOffline = subplebbit?.updatedAt && subplebbit.updatedAt < Date.now() / 1000 - 60 * 60;
const offlineAlert = updatedAt
? isBoardOffline && (
2024-10-15 13:10:47 +02:00
<div className={styles.offlineBoard}>
<Trans i18nKey='posts_last_synced_info' values={{ time: getFormattedTimeAgo(updatedAt) }} />
</div>
)
2024-10-15 13:10:47 +02:00
: t('subplebbit_offline_info');
2024-08-09 12:08:22 +02:00
useEffect(() => {
2024-09-20 15:38:16 +02:00
if (showReplyModal) {
2024-08-09 12:08:22 +02:00
setTimeout(() => {
if (textRef.current) {
textRef.current.focus();
}
}, 0);
}
2024-09-20 15:38:16 +02:00
}, [showReplyModal]);
useEffect(() => {
if (textRef.current) {
const len = textRef.current.value.length;
textRef.current.setSelectionRange(len, len);
}
}, []);
const contentPrefix = `c/${parentCid && Plebbit.getShortCid(parentCid)}\n`;
// enable spellcheck after the prefix is set
useEffect(() => {
if (showReplyModal && textRef.current) {
textRef.current.spellcheck = false;
textRef.current.value = contentPrefix + (selectedText || '');
setTimeout(() => {
if (textRef.current) {
textRef.current.spellcheck = true;
}
}, 100);
}
}, [showReplyModal, contentPrefix, selectedText]);
const handleContentInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value } = e.target;
if (!value.startsWith(contentPrefix)) {
e.target.value = contentPrefix + value.slice(contentPrefix.length);
}
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const contentWithoutPrefix = e.target.value.slice(contentPrefix.length);
const formattedContent = formatMarkdown(contentWithoutPrefix);
if (textRef.current && textRef.current.value !== formattedContent) {
setPublishReplyOptions({ content: formattedContent });
}
};
// on android, auto upload file to image hosting sites with open api
const [isUploading, setIsUploading] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const handleUpload = async () => {
try {
setIsUploading(true);
const result = await FileUploader.pickAndUploadMedia();
console.log('Upload result:', result);
if (result.url) {
setUrl(result.url);
if (urlRef.current) {
urlRef.current.value = result.url;
}
setPublishReplyOptions({ link: result.url || undefined });
if (result.fileName) {
setUploadedFileName(result.fileName);
}
}
} catch (error) {
2024-11-04 17:55:51 +01:00
console.error('Upload failed, ', error);
if (error instanceof Error && error.message !== 'File selection cancelled') {
2024-11-04 17:55:51 +01:00
setError(`${t('upload_failed')}, ${error.message}`);
} else if (typeof error === 'string' && error !== 'File selection cancelled') {
2024-11-04 17:55:51 +01:00
setError(`${t('upload_failed')}, ${error}`);
}
} finally {
setIsUploading(false);
}
};
2025-01-26 22:25:56 +01:00
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
if (displayName && !hasInitializedDisplayName.current) {
hasInitializedDisplayName.current = true;
setPublishReplyOptions({ displayName });
}
2025-01-26 23:06:49 +01:00
}, [displayName, setPublishReplyOptions]);
2025-01-26 22:25:56 +01:00
const modalContent = (
<div className={styles.container} ref={nodeRef}>
<div className={`replyModalHandle ${styles.title}`}>
{t('reply_to_cid', { cid: `c/${parentCid && Plebbit.getShortCid(parentCid)}`, interpolation: { escapeValue: false } })}
<button
className={styles.closeIcon}
onClick={(e) => {
e.stopPropagation();
closeModal();
}}
title='close'
/>
</div>
<div className={styles.replyForm}>
<div className={styles.name}>
<input
type='text'
defaultValue={displayName}
2024-06-05 22:18:45 +02:00
placeholder={displayName ? undefined : _.capitalize(t('name'))}
2024-08-08 17:48:45 +02:00
onChange={(e) => {
setAccount({ ...account, author: { ...account?.author, displayName: e.target.value } });
setPublishReplyOptions({ displayName: e.target.value || undefined });
}}
/>
</div>
<div className={styles.link}>
<input
type='text'
ref={urlRef}
placeholder={_.capitalize(t('link'))}
onChange={(e) => {
setUrl(e.target.value);
2024-08-08 17:48:45 +02:00
setPublishReplyOptions({ link: e.target.value || undefined });
}}
/>
</div>
<div className={styles.content}>
<textarea cols={48} rows={4} wrap='soft' ref={textRef} spellCheck={true} onInput={handleContentInput} onChange={handleContentChange} />
</div>
{!(isInAllView || isInSubscriptionsView) && offlineAlert}
<div className={styles.offlineAlert}></div>
<div className={styles.footer}>
{url && !isAndroid && (
<>
2024-11-05 12:48:28 +01:00
{t('link_type')}: <LinkTypePreviewer link={url} />
</>
)}
{isAndroid && (
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button onClick={handleUpload} disabled={isUploading}>
2024-11-04 17:42:14 +01:00
{isUploading ? t('uploading') : t('choose_file')}
</button>
</span>
2024-11-05 12:48:28 +01:00
<span className={styles.uploadFileName} title={uploadedFileName ? uploadedFileName : t('no_file_chosen')}>
{uploadedFileName ? uploadedFileName : t('no_file_chosen')}
</span>
</span>
)}
<span className={styles.spoilerButton}>
[
<label>
<input type='checkbox' onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
2024-05-31 17:29:19 +02:00
{_.capitalize(t('spoiler'))}?
</label>
]
</span>
<button className={styles.publishButton} onClick={onPublishReply}>
{t('post')}
</button>
</div>
2024-11-04 17:55:51 +01:00
{error && (
<div className={styles.error}>
{t('error')}: {error}
</div>
)}
</div>
</div>
);
2024-08-09 12:08:22 +02:00
return (
showReplyModal &&
(isMobile ? (
modalContent
) : (
<Draggable handle='.replyModalHandle' nodeRef={nodeRef}>
{modalContent}
</Draggable>
))
2024-04-28 20:08:41 +02:00
);
};
export default ReplyModal;