diff options
-rw-r--r-- | vnext/.eslintrc | 2 | ||||
-rw-r--r-- | vnext/package-lock.json | 50 | ||||
-rw-r--r-- | vnext/package.json | 1 | ||||
-rw-r--r-- | vnext/src/ui/Message.css | 242 | ||||
-rw-r--r-- | vnext/src/ui/Message.js | 160 | ||||
-rw-r--r-- | vnext/src/ui/MessageInput.js | 81 | ||||
-rw-r--r-- | vnext/src/ui/Thread.js | 2 |
7 files changed, 187 insertions, 351 deletions
diff --git a/vnext/.eslintrc b/vnext/.eslintrc index 8f2bd0ec..34c2965d 100644 --- a/vnext/.eslintrc +++ b/vnext/.eslintrc @@ -26,7 +26,7 @@ "settings": { "react": { "pragma": "React", - "version": "16.8" + "version": "18" } }, "rules": { diff --git a/vnext/package-lock.json b/vnext/package-lock.json index 55bd8712..8f1770ac 100644 --- a/vnext/package-lock.json +++ b/vnext/package-lock.json @@ -23,6 +23,7 @@ "react-cookie": "^4.1.1", "react-dom": "18.2.0", "react-hook-form": "^7.38.0", + "react-hot-toast": "^2.4.0", "react-router-dom": "^6.4.2", "react-use": "^17.4.0", "regenerator-runtime": "^0.13.10", @@ -5482,9 +5483,9 @@ "dev": true }, "node_modules/csstype": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", - "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, "node_modules/data-urls": { "version": "3.0.2", @@ -7648,6 +7649,14 @@ "integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=", "dev": true }, + "node_modules/goober": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.11.tgz", + "integrity": "sha512-5SS2lmxbhqH0u9ABEWq7WPU69a4i2pYcHeCxqaNq6Cw3mnrF0ghWNM4tEGid4dKy8XNIAUbuThuozDHHKJVh3A==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -13271,6 +13280,21 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-hot-toast": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz", + "integrity": "sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -19915,9 +19939,9 @@ } }, "csstype": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", - "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, "data-urls": { "version": "3.0.2", @@ -21482,6 +21506,12 @@ "integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=", "dev": true }, + "goober": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.11.tgz", + "integrity": "sha512-5SS2lmxbhqH0u9ABEWq7WPU69a4i2pYcHeCxqaNq6Cw3mnrF0ghWNM4tEGid4dKy8XNIAUbuThuozDHHKJVh3A==", + "requires": {} + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -25451,6 +25481,14 @@ "integrity": "sha512-gxWW1kMeru9xR1GoR+Iw4hA+JBOM3SHfr4DWCUKY0xc7Vv1MLsF109oHtBeWl9shcyPFx67KHru44DheN0XY5A==", "requires": {} }, + "react-hot-toast": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz", + "integrity": "sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==", + "requires": { + "goober": "^2.1.10" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/vnext/package.json b/vnext/package.json index cfcfee29..03957f0b 100644 --- a/vnext/package.json +++ b/vnext/package.json @@ -91,6 +91,7 @@ "react-cookie": "^4.1.1", "react-dom": "18.2.0", "react-hook-form": "^7.38.0", + "react-hot-toast": "^2.4.0", "react-router-dom": "^6.4.2", "react-use": "^17.4.0", "regenerator-runtime": "^0.13.10", diff --git a/vnext/src/ui/Message.css b/vnext/src/ui/Message.css deleted file mode 100644 index 06ad5270..00000000 --- a/vnext/src/ui/Message.css +++ /dev/null @@ -1,242 +0,0 @@ -@custom-media --viewport-desktop (width >=62.5rem); -@custom-media --viewport-mobile (width < 62.5rem); - -.msg-cont .ir { - padding: 12px; -} -.msg-cont .ir img { - max-width: 100%; - height: auto; -} -.msg-cont > .h, -.msg-cont .msg-header { - padding: 12px; -} -.msg-cont > .l { - border-top: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: space-around; - background: var(--background-color); -} -.msg-cont > .l a { - color: var(--dimmed-link-color); - margin-right: 15px; - font-size: small; -} -.msg-tags { - color: var(--dimmed-link-color); - margin-top: 12px; - min-height: 1px; -} -.badge, -.msg-tags > a { - color: var(--dimmed-link-color); - display: inline-block; - font-size: small; -} -.msgthread { - margin-bottom: 0; -} -.msg-cont { - background: var(--text-background-color); - border: 1px solid var(--border-color); - line-height: 140%; - margin-bottom: 12px; -} -.reply-new .msg-cont { - border-right: 5px solid #0C0; -} -.msg-ts { - font-size: small; - vertical-align: top; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: break-word; -} -.msg-ts, -.msg-ts > a { - color: var(--dimmed-link-color); -} -.msg-txt { - margin: 0 0 12px; - padding: 12px; - word-wrap: break-word; - overflow-wrap: break-word; -} -q:before, -q:after { - content: ""; -} -q, -blockquote { - border-left: 3px solid #CCC; - color: #666; - display: block; - margin: 10px 0 10px 10px; - padding-left: 10px; - word-break: break-word; -} -.msg-media { - text-align: center; -} -.msg-media img { - max-width: 100%; - height: auto; -} -.msg-links { - color: var(--dimmed-link-color); - font-size: small; - margin: 5px 0 0 0; - padding: 12px; -} -.msg-comments { - color: var(--dimmed-link-color); - font-size: small; - margin-top: 10px; - overflow: hidden; - text-indent: 10px; -} -.ta-wrapper { - border: 1px solid #DDD; - display: flex; - flex-grow: 1; -} -.msg-comment { - display: flex; - margin-top: 10px; -} -.msg-comment-hidden { - display: none; -} -.msg-comment textarea { - border: 0; - flex-grow: 1; - outline: none !important; - padding: 4px; - resize: vertical; - vertical-align: top; -} -.attach-photo { - cursor: pointer; -} -.attach-photo-active { - color: green; -} -.msg-comment input { - align-self: flex-start; - background: var(--background-color); - border: 1px solid var(--border-color); - color: #999; - margin: 0 0 0 6px; - position: sticky; - top: 70px; - vertical-align: top; - width: 50px; -} -.msg-recomms { - color: var(--dimmed-link-color); - background: var(--main-background-color); - font-size: small; - margin-bottom: 10px; - padding: 6px; - border-bottom: 1px solid var(--border-color); - overflow: hidden; - text-indent: 10px; -} -.msg-summary, -.msg-summary a { - color: var(--dimmed-link-color); - font-size: small; - padding: 12px; - text-align: right; -} -.msg-bubble { - padding: 12px; - display: inline-block; - background: var(--border-color); - color: #222; -} - -.msg-bubble-my { - color: #fff !important; - background: var(--link-color) !important; -} - -.msg-bubble-my a { - color: #fff !important; - text-decoration: underline; -} -#replies .msg-txt, -#private-messages .msg-txt { - margin: 0; -} -.title2 { - background: #fff; - margin: 20px 0; - padding: 10px 20px; -} -.title2-right { - float: right; - line-height: 24px; -} -#content .title2 h2 { - font-size: x-large; - margin: 0; -} - -.embedContainer { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - padding: 12px; - position: relative; -} -.embedContainer > * { - box-sizing: border-box; - flex-grow: 1; - margin: 3px; - min-width: 49%; -} -.embedContainer > .compact { - flex-grow: 0; -} -.embedContainer .picture img { - display: block; -} -.embedContainer img, -.embedContainer video { - max-width: 100%; - max-height: 80vh; -} -.embedContainer > .audio, -.embedContainer > .youtube { - min-width: 90%; -} -.embedContainer audio { - width: 100%; -} -.embedContainer iframe { - overflow: hidden; - resize: vertical; - display: block; -} -.msg-cont .nsfw .embedContainer img, -.msg-cont .nsfw .embedContainer video, -.msg-cont .nsfw .embedContainer iframe, -.msg-cont .nsfw .ir img { - opacity: 0.1; -} -.msg-cont .nsfw .embedContainer img:hover, -.msg-cont .nsfw .embedContainer video:hover, -.msg-cont .nsfw .embedContainer iframe:hover, -.msg-cont .nsfw .ir img:hover { - opacity: 1; -} - -@media (--viewport-desktop) { - .msg-cont { - width: 640px; - } -} diff --git a/vnext/src/ui/Message.js b/vnext/src/ui/Message.js index 620fe017..f8d3dc51 100644 --- a/vnext/src/ui/Message.js +++ b/vnext/src/ui/Message.js @@ -1,104 +1,117 @@ -import { memo, useEffect, useRef } from 'react'; +import React, { Fragment, memo, useEffect, useRef } from 'react'; -import { Link } from 'react-router-dom'; import moment from 'moment'; +import { Link } from 'react-router-dom'; import Icon from './Icon'; import Avatar from './Avatar'; import { UserLink } from './UserInfo'; import { format, embedUrls } from '../utils/embed'; -import './Message.css'; +/** + * @typedef {object} MessageProps + * @property { import('../client').Message } data data + * @property { import('../client').SecureUser } visitor visitor + */ /** * Message component - * @param {{data: import('../api').Message, visitor: import('../api').User, children: React.ReactElement}} props + * + * @param {React.PropsWithChildren<{}> & MessageProps} props props */ -export default function Message({ data, visitor, children }) { +export default function Message({ visitor, data, children }) { const isCode = (data.tags || []).indexOf('code') >= 0; const likesSummary = data.likes ? `${data.likes}` : 'Recommend'; const commentsSummary = data.replies ? `${data.replies}` : 'Comment'; /** - * @type {React.RefObject<HTMLDivElement>} + * @type {React.MutableRefObject<HTMLDivElement?>} */ - const embedRef = useRef(); + const embedRef = useRef(null); /** - * @type {React.RefObject<HTMLDivElement>} + * @type {React.MutableRefObject<HTMLDivElement?>} */ - const msgRef = useRef(); + const msgRef = useRef(null); useEffect(() => { - if (msgRef.current) { - embedUrls(msgRef.current.querySelectorAll('a'), embedRef.current); - if (!embedRef.current.hasChildNodes()) { - embedRef.current.style.display = 'none'; + const msg = msgRef.current; + const embed = embedRef.current; + if (msg && embed) { + embedUrls(msg.querySelectorAll('a'), embed); + if (!embed.hasChildNodes()) { + embed.style.display = 'none'; } } }, []); - const canComment = visitor.uid === data.user.uid || !data.ReadOnly; + const canComment = data.user && visitor.uid === data.user.uid || !data.ReadOnly; return ( <div className="msg-cont"> <Recommendations forMessage={data} /> <header className="h"> - <Avatar user={data.user}> - <div className="msg-ts"> - <Link to={{ pathname: `/${data.user.uname}/${data.mid}`, state: { msg: data } }}> - <time dateTime={data.timestamp} - title={moment.utc(data.timestamp).local().format('lll')}> - {moment.utc(data.timestamp).fromNow()} - </time> - </Link> - { - visitor.uid == data.user.uid && - <> - <span> · </span> - <Link to={{ pathname: '/post', state: { draft: data }}}>Edit</Link> - </> - } - </div> - - </Avatar> - <TagsList user={data.user} data={data.tags || []} /> + { + data.user && + <Avatar user={data.user}> + <div className="msg-ts"> + <Link to={`/${data.user.uname}/${data.mid}`}> + <time dateTime={data.timestamp} + title={moment.utc(data.timestamp).local().format('lll')}> + {moment.utc(data.timestamp).fromNow()} + </time> + </Link> + { + visitor.uid == data.user.uid && + <> + <span> · </span> + <Link to={{ + pathname: '/post', + query: { + mid: data.mid, + body: data.body + } + }}>Edit</Link> + </> + } + </div> + </Avatar> + } </header> { - data.body && + data.body && data.user && data.mid && <div className="msg-txt" ref={msgRef}> + <TagsList user={data.user} data={data.tags || []} /> <MessageContainer isCode={isCode} data={{ __html: format(data.body, data.mid.toString(), isCode) }} /> </div> } { - data.photo && + data.photo && data.attachment && data.attachment.small && <div className="msg-media"> - <a href={`//i.juick.com/p/${data.mid}.${data.attach}`} data-fname={`${data.mid}.${data.attach}`}> - <img src={`//i.juick.com/photos-512/${data.mid}.${data.attach}`} alt="" /> + <a href={`https://i.juick.com/p/${data.mid}.${data.attach}`} data-fname={`${data.mid}.${data.attach}`}> + <img src={data.attachment.small.url} width={data.attachment.small.width} height={data.attachment.small.height} alt="Message media" /> </a> </div> } <div className="embedContainer" ref={embedRef} /> <nav className="l"> - {visitor.uid === data.user.uid ? ( - <Link to={{ pathname: `/${data.user.uname}/${data.mid}` }} className="a-like msg-button"> + {data.user && visitor.uid === data.user.uid ? ( + <Link to={`/${data.user.uname}/${data.mid}`} className="a-like msg-button"> <Icon name="ei-heart" size="s" /> <span>{likesSummary}</span> </Link> ) : visitor.uid > 0 ? ( - <Link to={{ pathname: '/post', search: `?body=!+%23${data.mid}` }} className="a-like msg-button"> + <Link to={'/post'} className="a-like msg-button"> <Icon name="ei-heart" size="s" /> <span>{likesSummary}</span> </Link> ) : ( - <a href="/login" className="a-login msg-button"> - <Icon name="ei-heart" size="s" /> - <span>{likesSummary}</span> - </a> - )} - {canComment && ( - <> - <Link to={{ pathname: `/${data.user.uname}/${data.mid}`, state: { msg: data } }} className="a-comment msg-button"> - <Icon name="ei-comment" size="s" /> - <span>{commentsSummary}</span> - </Link> - </> + <Link to="/login" className="a-login msg-button"> + <Icon name="ei-heart" size="s" /> + <span>{likesSummary}</span> + </Link> + )} + {data.user && canComment && ( + <Link to={`/${data.user.uname}/${data.mid}`} className="a-comment msg-button"> + <Icon name="ei-comment" size="s" /> + <span>{commentsSummary}</span> + </Link> )} </nav> {children} @@ -107,39 +120,52 @@ export default function Message({ data, visitor, children }) { } /** - * @param {{isCode: boolean, data: {__html: string}}} props + * @param {{isCode: boolean, data: {__html: string}}} props props */ function MessageContainer({ isCode, data }) { - return isCode ? (<pre dangerouslySetInnerHTML={data} />) : (<p dangerouslySetInnerHTML={data} />); + return isCode ? (<pre dangerouslySetInnerHTML={data} />) : (<span dangerouslySetInnerHTML={data} />); } /** * Tags component - * @param {{user: import('../api').User, data: string[]}} props + * + * @param {{user: import('../client').User, data: string[]}} props props */ function Tags({ data, user }) { - return data.length > 0 && ( - <div className="msg-tags"> + return data.length > 0 ? ( + <span className="msg-tags"> { - data.map(tag => { - return (<Link key={tag} to={{ pathname: `/${user.uname}`, search: `?tag=${tag}` }} title={tag}>{tag}</Link>); - // @ts-ignore - }).reduce((prev, curr) => [prev, ', ', curr]) + data.map((tag, index) => ( + <Fragment key={tag}> + {index > 0 && ' '} + <Link key={tag} to={`/${user.uname}?tag=${tag}`} title={tag}> + {tag} + </Link> + </Fragment> + )) } - </div> - ); + </span> + ) : null; } const TagsList = memo(Tags); +/** + * + * @param {{forMessage: import('../client').Message}} props props + */ function Recommends({ forMessage }) { - const { likes, recommendations } = forMessage; + const { recommendations } = forMessage; + const likes = forMessage.likes || 0; return recommendations && recommendations.length > 0 && ( <div className="msg-recomms">{'♡ by '} { - recommendations.map(it => ( - <UserLink key={it.uri || it.uid} user={it} /> - )).reduce((prev, curr) => [prev, ', ', curr]) + recommendations.map((it, index) => ( + <Fragment key={it.uri || it.uid}> + {index > 0 && ', '} + <UserLink key={it.uri || it.uid} user={it} /> + </Fragment> + )) } { likes > recommendations.length && (<span> and {likes - recommendations.length} others</span>) diff --git a/vnext/src/ui/MessageInput.js b/vnext/src/ui/MessageInput.js index 6003a15c..aa4454a1 100644 --- a/vnext/src/ui/MessageInput.js +++ b/vnext/src/ui/MessageInput.js @@ -1,56 +1,63 @@ -import { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import Icon from './Icon'; import Button from './Button'; import UploadButton from './UploadButton'; +import toast from 'react-hot-toast'; /** * StackOverflow-driven development: https://stackoverflow.com/a/10158364/1097384 - * @param {HTMLTextAreaElement} el + * + * @param {HTMLTextAreaElement & {createTextRange?: Function}} el element */ function moveCaretToEnd(el) { if (typeof el.selectionStart == 'number') { el.selectionStart = el.selectionEnd = el.value.length; - // @ts-ignore } else if (typeof el.createTextRange != 'undefined') { + // Internet Explorer el.focus(); - // @ts-ignore var range = el.createTextRange(); range.collapse(false); range.select(); } } + +/** @external Promise */ + /** - * @typedef {Object} MessageInputProps - * @property {string} text - * @property {import('../api').Message=} data - * @property {function} onSend - * @property {number=} rows - * @property {string} children + * @typedef {object} MessageInputProps + * @property {string} text text + * @property {function({body:string, attach:string | File}):Promise<boolean>} onSend onSend + * @property {number=} rows rows + * @property {string=} placeholder placeholder */ /** * MessageInput - * @param {React.ReactNode & MessageInputProps} props + * + * @param {React.ComponentProps<React.FC> & MessageInputProps} props props */ -export default function MessageInput({ text, data, rows, children, onSend }) { +export default function MessageInput({ text, rows, placeholder, onSend }) { /** - * @type {React.MutableRefObject<HTMLTextAreaElement>} + * @type {React.MutableRefObject<HTMLTextAreaElement?>} */ - let textareaRef = useRef(); + let textareaRef = useRef(null); /** - * @type {React.MutableRefObject<HTMLInputElement>} + * @type {React.MutableRefObject<HTMLInputElement?>} */ - let fileinput = useRef(); + let fileinput = useRef(null); let updateFocus = () => { const isDesktop = window.matchMedia('(min-width: 62.5rem)'); if (isDesktop.matches) { - textareaRef.current.focus(); - moveCaretToEnd(textareaRef.current); + const textarea = textareaRef.current; + if (textarea) { + textarea.focus(); + moveCaretToEnd(textarea); + } } }; useEffect(() => { @@ -72,37 +79,43 @@ export default function MessageInput({ text, data, rows, children, onSend }) { const textChanged = (event) => { setBody(event.target.value); const el = textareaRef.current; - const offset = el.offsetHeight - el.clientHeight; - const height = el.scrollHeight + offset; - el.style.height = `${height + offset}px`; + if (el) { + const offset = el.offsetHeight - el.clientHeight; + const height = el.scrollHeight + offset; + el.style.height = `${height + offset}px`; + } }; const [attach, setAttach] = useState(''); let uploadValueChanged = (attach) => { setAttach(attach); }; - let onSubmit = (event) => { + let onSubmit = async (event) => { if (event.preventDefault) { event.preventDefault(); } const input = fileinput.current; - onSend({ - mid: data.mid, - rid: data.rid || 0, - body: body, - attach: attach ? input.files[0] : '', - to: data.to || {} - }); - setAttach(''); - setBody(''); - textareaRef.current.style.height = ''; - updateFocus(); + if (input && input.files) { + if (await onSend({ + body: body, + attach: attach ? input.files[0] : '' + })) { + setAttach(''); + setBody(''); + if (textareaRef.current) { + textareaRef.current.style.height = ''; + } + updateFocus(); + } else { + toast('Can not update this message'); + } + } }; return ( <form className="msg-comment-target" style={{ padding: '12px', width: '100%' }} onSubmit={onSubmit}> <div style={commentStyle}> <textarea onChange={textChanged} onKeyPress={handleCtrlEnter} ref={textareaRef} style={textInputStyle} - rows={rows || 1} placeholder={children} value={body} /> + rows={rows || 1} placeholder={placeholder} value={body || ''} /> <div style={inputBarStyle}> <UploadButton inputRef={fileinput} value={attach} onChange={uploadValueChanged} /> <Button onClick={onSubmit}><Icon name="ei-envelope" size="s" />Send</Button> diff --git a/vnext/src/ui/Thread.js b/vnext/src/ui/Thread.js index b5a855f9..15c169fc 100644 --- a/vnext/src/ui/Thread.js +++ b/vnext/src/ui/Thread.js @@ -11,7 +11,7 @@ import defaultAvatar from '../assets/av-96.png'; import { format, embedUrls } from '../utils/embed'; -import { getMessages, comment, update, markReadTracker, fetchUserUri, updateAvatar } from '../api'; +import { getMessages, comment, update, fetchUserUri } from '../api'; import { chatItemStyle } from './helpers/BubbleStyle'; |