aboutsummaryrefslogtreecommitdiff
path: root/vnext/src
diff options
context:
space:
mode:
Diffstat (limited to 'vnext/src')
-rw-r--r--vnext/src/ui/Message.css242
-rw-r--r--vnext/src/ui/Message.js160
-rw-r--r--vnext/src/ui/MessageInput.js81
-rw-r--r--vnext/src/ui/Thread.js2
4 files changed, 141 insertions, 344 deletions
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>&nbsp;&middot;&nbsp;</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>&nbsp;&middot;&nbsp;</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>&nbsp;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';