diff options
29 files changed, 520 insertions, 369 deletions
diff --git a/vnext/.eslintrc b/vnext/.eslintrc index 4413d33d..62404fed 100644 --- a/vnext/.eslintrc +++ b/vnext/.eslintrc @@ -69,6 +69,7 @@ "jest/prefer-to-have-length": "warn", "jest/valid-expect": "error", + "react/prop-types": "off", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" } diff --git a/vnext/package.json b/vnext/package.json index c8dcabd0..d60d042f 100644 --- a/vnext/package.json +++ b/vnext/package.json @@ -36,7 +36,6 @@ "optimize-css-assets-webpack-plugin": "^5.0.3", "postcss-loader": "^3.0.0", "postcss-preset-env": "^6.7.0", - "prop-types": "^15.7.2", "react-router-prop-types": "^1.0.4", "react-test-renderer": "^16.8.6", "style-loader": "^0.23.1", diff --git a/vnext/src/App.js b/vnext/src/App.js index ea63ae18..7e0c4007 100644 --- a/vnext/src/App.js +++ b/vnext/src/App.js @@ -67,12 +67,20 @@ export default function App() { }); }, [hash]); + /** + * @param {{ pathname: any; search: string; }[]} history + * @param {any} pathname + * @param {any} searchString + */ let search = (history, pathname, searchString) => { let location = {}; location.pathname = pathname; location.search = `?search=${searchString}`; history.push(location); }; + /** + * @param {import("./api").SecureUser} visitor + */ let auth = (visitor) => { setVisitor(prevState => { if (visitor.hash != prevState.hash) { diff --git a/vnext/src/api/index.js b/vnext/src/api/index.js index e6b1d2ef..330b3f9e 100644 --- a/vnext/src/api/index.js +++ b/vnext/src/api/index.js @@ -3,6 +3,63 @@ import cookies from 'react-cookies'; const apiBaseUrl = 'https://juick.com'; +/** + * @typedef {Object} Token + * @property {string} type + * @property {string} token + */ + +/** + * @typedef {Object} User + * @property {string} uname + * @property {number} uid + * @property {number=} unreadCount + * @property {string=} avatar + * @property {User[]=} read + * @property {User[]=} readers + * @property {number=} statsMyBL + * @property {string=} uri + */ + +/** + * @typedef {Object} SecureUserProperties + * @property {string} hash + * @property {Token[]} tokens + */ + +/** + * @typedef {User & SecureUserProperties} SecureUser + */ + +/** + * @typedef {Object} ChatProperties + * @property {number=} unreadCount + * @property {string=} lastMessageText + */ + +/** + * @typedef {User & ChatProperties} Chat + */ + +/** + * @typedef {Object} Message + * @property {string} body + * @property {number=} mid + * @property {number=} rid + * @property {boolean=} service + * @property {User} user + * @property {User=} to + * @property {string=} replyQuote + * @property {string[]=} tags + * @property {number=} likes + * @property {number=} replies + * @property {string=} photo + * @property {string=} attach + * @property {string=} timestamp + * @property {boolean=} ReadOnly + */ + + const client = axios.create({ baseURL: apiBaseUrl }); @@ -13,6 +70,12 @@ client.interceptors.request.use(config => { return config; }); +/** + * fetch my info + * @param {string} username + * @param {string} password + * @return {Promise<SecureUser, Error>} me object + */ export function me(username = '', password = '') { return new Promise((resolve, reject) => { client.get('/api/me', { @@ -30,14 +93,21 @@ export function me(username = '', password = '') { }); } +/** + * @param {string} username + */ export function info(username) { return client.get(`/api/info/${username}`); } + export function getChats() { return client.get('/api/groups_pms'); } +/** + * @param {string} userName + */ export function getChat(userName) { return client.get('/api/pm', { params: { @@ -46,6 +116,10 @@ export function getChat(userName) { }); } +/** + * @param {string} userName + * @param {string} body + */ export function pm(userName, body) { let form = new FormData(); form.set('uname', userName); @@ -53,12 +127,20 @@ export function pm(userName, body) { return client.post('/api/pm', form); } +/** + * @param {string} path + * @param {{ mid: any; }} params + */ export function getMessages(path, params) { return client.get(path, { params: params }); } +/** + * @param {string} body + * @param {string} attach + */ export function post(body, attach) { let form = new FormData(); form.append('attach', attach); @@ -66,10 +148,16 @@ export function post(body, attach) { return client.post('/api/post', form); } +/** + * @param {number} mid + * @param {number} rid + * @param {string} body + * @param {string} attach + */ export function comment(mid, rid, body, attach) { let form = new FormData(); - form.append('mid', mid); - form.append('rid', rid); + form.append('mid', mid.toString()); + form.append('rid', rid.toString()); form.append('body', body); form.append('attach', attach); return client.post('/api/comment', form); @@ -89,6 +177,9 @@ export function updateAvatar(newAvatar) { return client.post('/api/me/upload', form); } +/** + * @param {string} network + */ function socialLink(network) { return `${apiBaseUrl}/api/_${network}login?state=${window.location.protocol}//${window.location.host}${window.location.pathname}`; } @@ -101,10 +192,17 @@ export function vkLink() { return socialLink('vk'); } +/** + * @param {Message} msg + * @param {SecureUser} visitor + */ export function markReadTracker(msg, visitor) { return `${apiBaseUrl}/api/thread/mark_read/${msg.mid}-${msg.rid || 0}.gif?hash=${visitor.hash}`; } +/** + * @param {string} dataUri + */ export function fetchUserUri(dataUri) { return new Promise((resolve, reject) => { let form = new FormData(); diff --git a/vnext/src/index.js b/vnext/src/index.js index e48d004c..5f45ecc2 100644 --- a/vnext/src/index.js +++ b/vnext/src/index.js @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import './index.css'; -function LoadingView(props) { +function LoadingView() { return ( <div id="content"> <div className="lds-ripple"><div></div><div></div></div> diff --git a/vnext/src/ui/Avatar.css b/vnext/src/ui/Avatar.css index f48f2b9e..9f7d22e3 100644 --- a/vnext/src/ui/Avatar.css +++ b/vnext/src/ui/Avatar.css @@ -13,8 +13,8 @@ } .info-avatar img { - max-height: 24px; - max-width: 24px; + max-height: 48px; + max-width: 48px; padding: 6px; vertical-align: middle; } diff --git a/vnext/src/ui/Avatar.js b/vnext/src/ui/Avatar.js index ecce4e9f..e08c1ba4 100644 --- a/vnext/src/ui/Avatar.js +++ b/vnext/src/ui/Avatar.js @@ -1,14 +1,23 @@ import React, { memo } from 'react'; -import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { UserType } from './Types'; - import Icon from './Icon'; import './Avatar.css'; -function Avatar({ user, style, link, children}) { +/** + * @typedef {Object} AvatarProps + * @property {import('../api').User} user + * @property {React.CSSProperties=} style + * @property {string=} link + * @property {React.ReactNode=} children + */ + +/** + * Avatar component + * @param {AvatarProps} props + */ +function Avatar({ user, style, link, children }) { return ( <div className="Avatar" style={style}> <div className="msg-avatar"> @@ -35,10 +44,3 @@ function Avatar({ user, style, link, children}) { } export default memo(Avatar); - -Avatar.propTypes = { - user: UserType, - link: PropTypes.string, - style: PropTypes.object, - children: PropTypes.node -}; diff --git a/vnext/src/ui/Button.js b/vnext/src/ui/Button.js index 18cab0a7..033c972c 100644 --- a/vnext/src/ui/Button.js +++ b/vnext/src/ui/Button.js @@ -2,6 +2,9 @@ import React from 'react'; import './Button.css'; +/** + * @param {React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>} props + */ function Button(props) { return ( <button className="Button" {...props} /> diff --git a/vnext/src/ui/Chat.js b/vnext/src/ui/Chat.js index a1254a10..00e8eb3c 100644 --- a/vnext/src/ui/Chat.js +++ b/vnext/src/ui/Chat.js @@ -1,7 +1,4 @@ import React, { useEffect, useState, useCallback } from 'react'; -import PropTypes from 'prop-types'; -import ReactRouterPropTypes from 'react-router-prop-types'; -import { UserType } from './Types'; import moment from 'moment'; import PM from './PM'; @@ -12,20 +9,19 @@ import { getChat, pm } from '../api'; import './Chat.css'; +/** + * @typedef {Object} ChatProps + * @property {import('../api').SecureUser} visitor + * @property {EventSource} connection + * @property {import('react-router').match} match + */ + +/** + * Chat component + * @param {ChatProps} props + */ export default function Chat(props) { const [chats, setChats] = useState([]); - useEffect(() => { - if (props.connection.addEventListener) { - props.connection.addEventListener('msg', onMessage); - } - loadChat(props.match.params.user); - console.log(props.connection); - return () => { - if (props.connection.removeEventListener) { - props.connection.removeEventListener('msg', onMessage); - } - }; - }, [props.connection, onMessage, loadChat, props.match.params.user]); let loadChat = useCallback((uname) => { const { hash } = props.visitor; @@ -53,6 +49,18 @@ export default function Chat(props) { loadChat(props.match.params.user); }).catch(console.log); }; + useEffect(() => { + if (props.connection.addEventListener) { + props.connection.addEventListener('msg', onMessage); + } + loadChat(props.match.params.user); + console.log(props.connection); + return () => { + if (props.connection.removeEventListener) { + props.connection.removeEventListener('msg', onMessage); + } + }; + }, [props.connection, onMessage, loadChat, props.match.params.user]); const uname = props.match.params.user; return ( <div className="msg-cont"> @@ -77,9 +85,3 @@ export default function Chat(props) { </div> ); } - -Chat.propTypes = { - visitor: UserType.isRequired, - match: ReactRouterPropTypes.match.isRequired, - connection: PropTypes.object.isRequired -}; diff --git a/vnext/src/ui/Contact.js b/vnext/src/ui/Contact.js index 24aabe15..9e6416bb 100644 --- a/vnext/src/ui/Contact.js +++ b/vnext/src/ui/Contact.js @@ -1,10 +1,18 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { UserType } from './Types'; import Avatar from './Avatar'; -function Contact({ user, style, ...rest }) { +/** + * @typedef {Object} ContactProps + * @property {import('../api').Chat} user + * @property {React.CSSProperties} style + */ + +/** + * Contact component + * @param {ContactProps} props + */ +function Contact({ user, style }) { return ( <Avatar user={user} link={`/pm/${user.uname}`} style={style}> {user.unreadCount && <span className="badge">{user.unreadCount}</span>} @@ -14,8 +22,3 @@ function Contact({ user, style, ...rest }) { } export default React.memo(Contact); - -Contact.propTypes = { - user: UserType, - style: PropTypes.object -}; diff --git a/vnext/src/ui/Feeds.js b/vnext/src/ui/Feeds.js index e44de1ec..39c1296b 100644 --- a/vnext/src/ui/Feeds.js +++ b/vnext/src/ui/Feeds.js @@ -1,6 +1,5 @@ import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import ReactRouterPropTypes from 'react-router-prop-types'; + import { Link } from 'react-router-dom'; import qs from 'qs'; import moment from 'moment'; @@ -11,7 +10,6 @@ import Spinner from './Spinner'; import UserInfo from './UserInfo'; import { getMessages } from '../api'; -import { UserType } from './Types'; export function Discover(props) { let search = qs.parse(props.location.search.substring(1)); @@ -31,6 +29,10 @@ export function Discussions(props) { return (<Feed authRequired={false} query={query} {...props} />); } +/** + * Blog page + * @param {{match: import('react-router').match, location: import('history').Location}} props + */ export function Blog(props) { const { user } = props.match.params; let search = qs.parse(props.location.search.substring(1)); @@ -113,7 +115,7 @@ function Feed(props) { newFilter[pageParam] = pageValue; return `?${qs.stringify(newFilter)}`; }; - const { tag } = qs.parse(location.search.substring(1) || {}); + const { tag } = qs.parse(location.search.substring(1)) || {}; const nodes = ( <> { @@ -142,7 +144,7 @@ function Feed(props) { <div className="msgs">{nodes}</div> ) : error ? <div>error</div> : loading ? <div className="msgs"><Spinner /><Spinner /><Spinner /><Spinner /></div> : <div>No more messages</div>; } - +/* Discover.propTypes = { location: ReactRouterPropTypes.location.isRequired, search: PropTypes.string @@ -170,3 +172,4 @@ Feed.propTypes = { pageParam: PropTypes.string.isRequired }) }; +*/
\ No newline at end of file diff --git a/vnext/src/ui/Header.js b/vnext/src/ui/Header.js index b25879d4..2d042bfe 100644 --- a/vnext/src/ui/Header.js +++ b/vnext/src/ui/Header.js @@ -13,11 +13,10 @@ export default function Header({ children }) { let wScrollBefore = useRef(0); let wScrollDiff = useRef(0); - useEffect(() => { - window.addEventListener('scroll', () => (!window.requestAnimationFrame) - ? throttle(250, updateHeader) - : window.requestAnimationFrame(updateHeader), false); - }, [updateHeader]); + /** + * @param {number} delay + * @param {{ (): void; apply?: any; }} fn + */ let throttle = (delay, fn) => { var last, deferTimer; return function () { @@ -45,7 +44,7 @@ export default function Header({ children }) { if (wScrollCurrent.current <= 0) { // scrolled to the very top; element sticks to the top header.classList.remove(elClassHidden); - } else if (wScrollDiff > 0 && header.classList.contains(elClassHidden)) { + } else if (wScrollDiff.current > 0 && header.classList.contains(elClassHidden)) { // scrolled up; element slides in header.classList.remove(elClassHidden); } else if (wScrollDiff.current < 0) { @@ -60,6 +59,12 @@ export default function Header({ children }) { } wScrollBefore.current = wScrollCurrent.current; }, []); + + useEffect(() => { + window.addEventListener('scroll', () => (!window.requestAnimationFrame) + ? throttle(250, updateHeader) + : window.requestAnimationFrame(updateHeader), false); + }, [updateHeader]); return ReactDOM.createPortal(children, header); } diff --git a/vnext/src/ui/Icon.js b/vnext/src/ui/Icon.js index faf1a704..255bba34 100644 --- a/vnext/src/ui/Icon.js +++ b/vnext/src/ui/Icon.js @@ -1,7 +1,19 @@ -import React from 'react'; +import React, { memo } from 'react'; import PropTypes from 'prop-types'; -const Icon = React.memo(props => { +/** + * @typedef {Object} IconProps + * @property {string} size + * @property {string=} className + * @property {string} name + * @property {boolean=} noFill + */ + + /** + * Icon inner component + * @param {IconProps} props - icon props + */ +function IconElement(props) { var size = props.size ? ' icon--' + props.size : ''; var className = props.className ? ' ' + props.className : ''; var klass = 'icon' + (!props.noFill ? ' icon--' + props.name : '') + size + className; @@ -14,8 +26,12 @@ const Icon = React.memo(props => { { className: klass }, wrapSpinner(Icon, klass) ); -}); +} +/** + * @param {React.ReactElement} Html + * @param {string} klass + */ function wrapSpinner(Html, klass) { if (klass.indexOf('spinner') > -1) { return React.createElement( @@ -28,9 +44,9 @@ function wrapSpinner(Html, klass) { } } -export default Icon; +export default memo(IconElement); -Icon.propTypes = { +IconElement.propTypes = { size: PropTypes.string.isRequired, name: PropTypes.string.isRequired, className: PropTypes.string, diff --git a/vnext/src/ui/Input.js b/vnext/src/ui/Input.js index c74d595d..e4fdefa0 100644 --- a/vnext/src/ui/Input.js +++ b/vnext/src/ui/Input.js @@ -1,17 +1,23 @@ import React from 'react'; -import PropTypes from 'prop-types'; import './Input.css'; +/** + * @typedef {Object} InputProps + * @property {string} name + * @property {string} value + * @property {string=} placeholder + * @property {React.CSSProperties=} rest + */ + +/** + * Input component + * @param {InputProps} props + */ function Input({ name, value, ...rest }) { return ( <input className="input" name={name} value={value} {...rest} /> ); } -Input.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired -}; - export default React.memo(Input); diff --git a/vnext/src/ui/Login.js b/vnext/src/ui/Login.js index 649fadd4..883a4eab 100644 --- a/vnext/src/ui/Login.js +++ b/vnext/src/ui/Login.js @@ -1,10 +1,7 @@ -import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; +import React, { useEffect } from 'react'; -import ReactRouterPropTypes from 'react-router-prop-types'; import { withRouter } from 'react-router-dom'; -import { UserType } from './Types'; import Icon from './Icon'; import Button from './Button'; import Input from './Input'; @@ -14,6 +11,18 @@ import { me, facebookLink, vkLink } from '../api'; import './Login.css'; +/** + * @typedef {Object} LoginProps + * @property {import('../api').SecureUser} visitor + * @property {import('history').History} history + * @property {import('history').Location} location + * @property {any} onAuth + */ + +/** + * Login page + * @param {LoginProps} props + */ function Login({ visitor, history, location, onAuth }) { useEffect(() => { @@ -26,6 +35,9 @@ function Login({ visitor, history, location, onAuth }) { const [formState, { text, password }] = useFormState(); + /** + * @param {React.SyntheticEvent} event + */ let onSubmit = (event) => { event.preventDefault(); me(formState.values.username, formState.values.password) @@ -66,13 +78,6 @@ function Login({ visitor, history, location, onAuth }) { export default withRouter(Login); -Login.propTypes = { - visitor: UserType, - history: ReactRouterPropTypes.history.isRequired, - location: ReactRouterPropTypes.location, - onAuth: PropTypes.func -}; - const socialButtonsStyle = { display: 'flex', justifyContent: 'space-evenly', diff --git a/vnext/src/ui/Message.js b/vnext/src/ui/Message.js index fd225282..51ff12ec 100644 --- a/vnext/src/ui/Message.js +++ b/vnext/src/ui/Message.js @@ -1,9 +1,8 @@ import React, { useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; + import { Link } from 'react-router-dom'; import moment from 'moment'; -import { UserType, MessageType } from './Types'; import Icon from './Icon'; import Avatar from './Avatar'; import { UserLink } from './UserInfo'; @@ -12,11 +11,21 @@ import { format, embedUrls } from '../utils/embed'; import './Message.css'; -export default function Message({ data, visitor, children, ...rest }) { +/** + * Message component + * @param {{data: import('../api').Message, visitor: import('../api').User, children: Element}} props + */ +export default function Message({ data, visitor, 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>} + */ const embedRef = useRef(); + /** + * @type {React.RefObject<HTMLDivElement>} + */ const msgRef = useRef(); useEffect(() => { if (msgRef.current) { @@ -26,6 +35,7 @@ export default function Message({ data, visitor, children, ...rest }) { } } }, []); + const canComment = visitor.uid === data.user.uid || !data.ReadOnly; return ( <div className="msg-cont"> <Recommendations forMessage={data} /> @@ -53,7 +63,7 @@ export default function Message({ data, visitor, children, ...rest }) { { data.body && <div className="msg-txt" ref={msgRef}> - <MessageContainer isCode={isCode} data={{ __html: format(data.body, data.mid, isCode) }} /> + <MessageContainer isCode={isCode} data={{ __html: format(data.body, data.mid.toString(), isCode) }} /> </div> } { @@ -82,7 +92,7 @@ export default function Message({ data, visitor, children, ...rest }) { <span>{likesSummary}</span> </a> )} - {!data.ReadOnly | (visitor.uid === data.user.uid) && ( + {canComment && ( <> <Link to={{ pathname: `/${data.user.uname}/${data.mid}`, state: { msg: data } }} className="a-comment msg-button"> <Icon name="ei-comment" size="s" /> @@ -96,11 +106,18 @@ export default function Message({ data, visitor, children, ...rest }) { ); } +/** + * @param {{isCode: boolean, data: {__html: string}}} props + */ function MessageContainer({ isCode, data }) { return isCode ? (<pre dangerouslySetInnerHTML={data} />) : (<p dangerouslySetInnerHTML={data} />); } -function Tags({ data, user, ...rest }) { +/** + * Tags component + * @param {{user: import('../api').User, data: string[]}} props + */ +function Tags({ data, user }) { return data.length > 0 && ( <div className="msg-tags"> { @@ -114,7 +131,7 @@ function Tags({ data, user, ...rest }) { const TagsList = React.memo(Tags); -function Recommends({ forMessage, ...rest }) { +function Recommends({ forMessage }) { const { likes, recommendations } = forMessage; return recommendations && recommendations.length > 0 && ( <div className="msg-recomms">{'♡ by '} @@ -131,20 +148,3 @@ function Recommends({ forMessage, ...rest }) { } const Recommendations = React.memo(Recommends); - -Message.propTypes = { - data: MessageType, - visitor: UserType.isRequired, - children: PropTypes.node -}; - -MessageContainer.propTypes = { - isCode: PropTypes.bool.isRequired, - data: PropTypes.object.isRequired -}; - -Tags.propTypes = { - user: UserType.isRequired, - data: PropTypes.array -}; - diff --git a/vnext/src/ui/MessageInput.js b/vnext/src/ui/MessageInput.js index 63c2ee79..bc9ddd54 100644 --- a/vnext/src/ui/MessageInput.js +++ b/vnext/src/ui/MessageInput.js @@ -1,7 +1,4 @@ import React, { useState, useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; - -import { MessageType } from './Types'; import Icon from './Icon'; import Button from './Button'; @@ -9,7 +6,10 @@ import Button from './Button'; import UploadButton from './UploadButton'; -// StackOverflow-driven development: https://stackoverflow.com/a/10158364/1097384 +/** + * StackOverflow-driven development: https://stackoverflow.com/a/10158364/1097384 + * @param {HTMLTextAreaElement} el + */ function moveCaretToEnd(el) { if (typeof el.selectionStart == 'number') { el.selectionStart = el.selectionEnd = el.value.length; @@ -21,8 +21,25 @@ function moveCaretToEnd(el) { } } +/** + * @typedef {Object} MessageInputProps + * @property {string} text + * @property {import('../api').Message} data + * @property {function} onSend + */ + +/** + * MessageInput + * @param {HTMLTextAreaElement & MessageInputProps} props + */ export default function MessageInput({ text, data, rows, children, onSend }) { + /** + * @type {React.MutableRefObject<HTMLTextAreaElement>} + */ let textareaRef = useRef(); + /** + * @type {React.MutableRefObject<HTMLInputElement>} + */ let fileinput = useRef(); let updateFocus = () => { @@ -114,11 +131,3 @@ const textInputStyle = { outline: 'none', padding: '4px' }; - -MessageInput.propTypes = { - children: PropTypes.node, - data: MessageType.isRequired, - onSend: PropTypes.func.isRequired, - rows: PropTypes.string, - text: PropTypes.string -}; diff --git a/vnext/src/ui/NavigationIcon.js b/vnext/src/ui/NavigationIcon.js index 0a22ac57..9594d61f 100644 --- a/vnext/src/ui/NavigationIcon.js +++ b/vnext/src/ui/NavigationIcon.js @@ -5,6 +5,15 @@ import Icon from './Icon'; import './NavigationIcon.css'; +/** + * @typedef {Object} NavigationIconProps + * @property {(event: React.MouseEvent) => void} onToggle + */ + + /** + * Navigation icon + * @param {NavigationIconProps} props + */ function NavigationIcon(props) { return ( <div id="navicon" className="mobile" onClick={props.onToggle}> diff --git a/vnext/src/ui/PM.js b/vnext/src/ui/PM.js index 08db523f..a25580e5 100644 --- a/vnext/src/ui/PM.js +++ b/vnext/src/ui/PM.js @@ -1,7 +1,5 @@ import React from 'react'; -import { UserType, MessageType } from './Types'; - import Avatar from './Avatar'; import { format } from '../utils/embed'; import { bubbleStyle, chatItemStyle } from './helpers/BubbleStyle'; @@ -13,7 +11,7 @@ function PM(props) { <div style={chatItemStyle(props.visitor, chat)}> <Avatar user={chat.user} /> <div style={bubbleStyle(props.visitor, chat)}> - <p dangerouslySetInnerHTML={{ __html: format(chat.body) }} /> + <p dangerouslySetInnerHTML={{ __html: format(chat.body, 0, false) }} /> </div> </div> </li> @@ -21,8 +19,9 @@ function PM(props) { } export default React.memo(PM); - +/* PM.propTypes = { chat: MessageType.isRequired, visitor: UserType.isRequired }; +*/ diff --git a/vnext/src/ui/Post.js b/vnext/src/ui/Post.js index 662f9f78..99189632 100644 --- a/vnext/src/ui/Post.js +++ b/vnext/src/ui/Post.js @@ -1,8 +1,5 @@ import React, { useState } from 'react'; -import ReactRouterPropTypes from 'react-router-prop-types'; -import { UserType } from './Types'; - import qs from 'qs'; import Button from './Button'; @@ -49,9 +46,10 @@ export default function Post({ location, visitor, history }) { </div> ); } - +/* Post.propTypes = { location: ReactRouterPropTypes.location, history: ReactRouterPropTypes.history.isRequired, visitor: UserType }; +*/
\ No newline at end of file diff --git a/vnext/src/ui/Settings.js b/vnext/src/ui/Settings.js index cf6926f8..61ac91d4 100644 --- a/vnext/src/ui/Settings.js +++ b/vnext/src/ui/Settings.js @@ -1,17 +1,13 @@ import React, { useState, useEffect, useRef } from 'react'; -import PropTypes from 'prop-types'; -import ReactRouterPropTypes from 'react-router-prop-types'; import { me, updateAvatar } from '../api'; -import { UserType } from './Types'; - import Button from './Button'; import Icon from './Icon'; import UploadButton from './UploadButton'; import Avatar from './Avatar'; -function ChangeAvatarForm({ visitor }) { +function ChangeAvatarForm({ visitor, onChange }) { const [avatar, setAvatar] = useState(''); const [preview, setPreview] = useState(); const avatarInput = useRef(); @@ -37,7 +33,7 @@ function ChangeAvatarForm({ visitor }) { updateAvatar(avatarInput.current.files[0]).then(() => { avatarChanged(''); me().then(visitor => { - this.props.onChange(visitor); + onChange(visitor); }); }); }; @@ -52,185 +48,173 @@ function ChangeAvatarForm({ visitor }) { </form> ); } - +/* ChangeAvatarForm.propTypes = { - visitor: UserType.isRequired + visitor: UserType.isRequired, + onChange: PropTypes.func.isRequired }; +*/ +export default function Settings({ visitor, onChange }) { -export default class Settings extends React.Component { - constructor(props) { - super(props); - this.state = { - settings: { - }, - me: {} - }; - } - componentDidMount() { - me().then(visitor => { - this.setState({ - me: visitor - }); - }); - } - - passwordChanged = (event) => { - let newState = update(this.state, { - settings: { password: { $set: event.target.value } } - }); - this.setState(newState); - } - onSubmitPassword = (event) => { + let passwordChanged = (event) => { + console.log('password changed'); + }; + let onSubmitPassword = (event) => { if (event.preventDefault) { event.preventDefault(); } console.log('password update'); - } - emailChanged = (event) => { - let newState = update(this.state, { - me: { activeEmail: { $set: event.target.value } } - }); - this.setState(newState); + }; + let emailChanged = (event) => { console.log('email update'); - } - disableTelegram = () => { + }; + let disableTelegram = () => { console.log('telegram disable'); - } - disableFacebook = (event) => { + }; + let disableFacebook = (event) => { if (event.preventDefault) { event.preventDefault(); } console.log('facebook disable'); - } - enableFacebook = (event) => { + }; + let enableFacebook = (event) => { if (event.preventDefault) { event.preventDefault(); } console.log('facebook enable'); - } - disableTwitter = () => { + }; + let disableTwitter = () => { console.log('twitter disable'); - } + }; + let deleteJid = () => { - render() { - const { me, settings } = this.state; - return ( - <div className="msg-cont"> - <fieldset> - <legend><Icon name="ei-user" size="m" />Changing your avatar</legend> - <ChangeAvatarForm visitor={me} /> - </fieldset> - <fieldset> - <legend><Icon name="ei-unlock" size="m" />Changing your password</legend> + }; + let addEmail = () => { + + }; + let deleteEmail = () => { + + }; + return ( + <div className="msg-cont"> + <fieldset> + <legend><Icon name="ei-user" size="m" />Changing your avatar</legend> + <ChangeAvatarForm visitor={visitor} onChange={onChange} /> + </fieldset> + <fieldset> + <legend><Icon name="ei-unlock" size="m" />Changing your password</legend> + <form> + <p>Change password: <input type="password" name="password" size="8" + onChange={passwordChanged} /> + <Button onClick={onSubmitPassword}>Update</Button><br /> + <i>(max. length - 16 symbols)</i></p> + </form> + </fieldset> + <fieldset> + <legend><Icon name="ei-sc-telegram" size="m" />Telegram</legend> + {visitor.telegramName ? ( <form> - <p>Change password: <input type="password" name="password" size="8" onChange={this.passwordChanged} /> - <Button onClick={this.onSubmitPassword}>Update</Button><br /> - <i>(max. length - 16 symbols)</i></p> - </form> - </fieldset> - <fieldset> - <legend><Icon name="ei-sc-telegram" size="m" />Telegram</legend> - {me.telegramName ? ( - <form> - <div>Telegram: <b> {me.telegramName} </b>— + <div>Telegram: <b> {visitor.telegramName} </b> — <Button onClick={this.disableTelegram}>Disable</Button> - </div> - </form> - ) : ( - <p>To connect Telegram account: send any text message to <a href="https://telegram.me/Juick_bot">@Juick_bot</a> - </p> - )} - </fieldset> - {me.jids && ( - <form> - <fieldset> - <legend>XMPP accounts - </legend> - <p>Your accounts:</p> - <p> - { - me.jids.map(jid => - <React.Fragment key={jid}> - <label><input type="radio" name="delete" value={jid} />{jid}</label><br /> - </React.Fragment> - ) - } - </p> - { - me.jids && me.jids.length > 1 && - <p><Button onClick={this.deleteJid}>Delete</Button></p> - } - <p>To add new jabber account: send any text message to <a href="xmpp:juick@juick.com?message;body=login">juick@juick.com</a> - </p> - </fieldset> + </div> </form> - )} - <fieldset> - <legend><Icon name="ei-envelope" size="m" />E-mail</legend> - <form> - <p>Add account:<br /> - <input type="text" name="account" /> - <input type="hidden" name="page" value="email-add" /> - <Button onClick={this.addEmail}>Add</Button> + ) : ( + <p>To connect Telegram account: send any text message to <a href="https://telegram.me/Juick_bot">@Juick_bot</a> </p> - </form> - <form> + )} + </fieldset> + {me.jids && ( + <form> + <fieldset> + <legend>XMPP accounts + </legend> <p>Your accounts:</p> <p> { - me.emails ? me.emails.map(email => - <React.Fragment key={email}> - <label> - <input type="radio" name="account" value={email} />{email} - </label> - <br /> + visitor.jids.map(jid => + <React.Fragment key={jid}> + <label><input type="radio" name="delete" value={jid} />{jid}</label><br /> </React.Fragment> - ) : '-' + ) } </p> { - me.emails && me.emails.length > 1 && - <Button onClick={this.deleteEmail}>Delete</Button> + visitor.jids && visitor.jids.length > 1 && + <p><Button onClick={deleteJid}>Delete</Button></p> } - </form> + <p>To add new jabber account: send any text message to <span><a href="xmpp:juick@juick.com?message;body=login">juick@juick.com</a></span> + </p> + </fieldset> + </form> + )} + <fieldset> + <legend><Icon name="ei-envelope" size="m" />E-mail</legend> + <form> + <p>Add account:<br /> + <input type="text" name="account" /> + <input type="hidden" name="page" value="email-add" /> + <Button onClick={addEmail}>Add</Button> + </p> + </form> + <form> + <p>Your accounts:</p> + <p> + { + visitor.emails ? visitor.emails.map(email => + <React.Fragment key={email}> + <label> + <input type="radio" name="account" value={email} />{email} + </label> + <br /> + </React.Fragment> + ) : '-' + } + </p> { - me.emails && - <> - {/** email_off **/} - <form> - You can receive notifications to email:<br /> - Sent to <select name="account" value={me.activeEmail || 'Disabled'} onChange={this.emailChanged}> - <option value="">Disabled</option> - { - me.emails.map(email => - <option key={email} value={email}> - {email} - </option> - ) - } - </select> - </form> - {/** /email_off **/} - <p> </p> - <p>You can post to Juick via e-mail. Send your <u>plain text</u> messages to <span><a href="mailto:juick@juick.com">juick@juick.com</a></span>. You can attach one photo or video file.</p> - </> + visitor.emails && visitor.emails.length > 1 && + <Button onClick={deleteEmail}>Delete</Button> } - </fieldset> - <fieldset> - <legend><Icon name="ei-sc-facebook" size="m" />Facebook</legend> - {me.facebookStatus && me.facebookStatus.connected ? ( - me.facebookStatus.crosspostEnabled ? + </form> + { + visitor.emails && + <> + {/** email_off **/} + <form> + You can receive notifications to email:<br /> + Sent to <select name="account" value={me.activeEmail || 'Disabled'} onChange={emailChanged}> + <option value="">Disabled</option> + { + visitor.emails.map(email => + <option key={email} value={email}> + {email} + </option> + ) + } + </select> + </form> + {/** /email_off **/} + <p> </p> + <p>You can post to Juick via e-mail. Send your <u>plain text</u> messages to <a href="mailto:juick@juick.com">juick@juick.com</a>. + You can attach one photo or video file.</p> + </> + } + </fieldset> + <fieldset> + <legend><Icon name="ei-sc-facebook" size="m" />Facebook</legend> + { + visitor.facebookStatus && visitor.facebookStatus.connected ? ( + visitor.facebookStatus.crosspostEnabled ? <form> <div> - Facebook: <b>Enabled</b> — - <Button onClick={this.disableFacebook}>Disable</Button> + Facebook: <b>Enabled</b>— + <Button onClick={disableFacebook}>Disable</Button> </div> </form> : <form> <div> - Facebook: <b>Disabled</b> — - <Button onClick={this.enableFacebook}>Enable</Button> + Facebook: <b>Disabled</b>— + <Button onClick={enableFacebook}>Enable</Button> </div> </form> ) : ( @@ -240,29 +224,29 @@ export default class Settings extends React.Component { </a> </p> )} - </fieldset> - <fieldset> - <legend><Icon name="ei-sc-twitter" size="m" />Twitter</legend> - {me.twitterName ? - <form action="/settings" method="post"> - <div>Twitter: <b>{me.twitterName}</b> — + </fieldset> + <fieldset> + <legend><Icon name="ei-sc-twitter" size="m" />Twitter</legend> + {visitor.twitterName ? + <form action="/settings" method="post"> + <div>Twitter: <b>{visitor.twitterName}</b>— <input type="hidden" name="page" value="twitter-del" /> - <Button onClick={this.disableTwitter}>Disable</Button> - </div> - </form> - : - <p>Cross-posting to Twitter: <a href="/_twitter"><img src="//static.juick.com/twitter-connect.png" - alt="Connect to Twitter" /></a></p> - } - </fieldset> + <Button onClick={disableTwitter}>Disable</Button> + </div> + </form> + : + <p>Cross-posting to Twitter: <a href="/_twitter"><img src="//static.juick.com/twitter-connect.png" + alt="Connect to Twitter" /></a></p> + } + </fieldset> - </div> - ); - } + </div> + ); } - +/* Settings.propTypes = { visitor: UserType.isRequired, onChange: PropTypes.func.isRequired }; +*/ diff --git a/vnext/src/ui/Thread.js b/vnext/src/ui/Thread.js index f965e80c..ae5e4f3c 100644 --- a/vnext/src/ui/Thread.js +++ b/vnext/src/ui/Thread.js @@ -1,8 +1,4 @@ import React, { useEffect, useState, useRef, useCallback } from 'react'; -import PropTypes from 'prop-types'; - -import ReactRouterPropTypes from 'react-router-prop-types'; -import { UserType, MessageType } from './Types'; import Message from './Message'; import MessageInput from './MessageInput'; @@ -101,7 +97,7 @@ function Comment({ msg, draft, visitor, active, setActive, onStartEditing, postC </div> ); } - +/* Comment.propTypes = { msg: MessageType.isRequired, draft: PropTypes.string.isRequired, @@ -110,7 +106,7 @@ Comment.propTypes = { setActive: PropTypes.func.isRequired, onStartEditing: PropTypes.func.isRequired, postComment: PropTypes.func.isRequired -}; +};*/ export default function Thread(props) { const [message, setMessage] = useState((props.location.state || {}).msg || {}); @@ -118,22 +114,8 @@ export default function Thread(props) { const [loading, setLoading] = useState(false); const [active, setActive] = useState(0); const [editing, setEditing] = useState({}); - useEffect(() => { - setActive(0); - loadReplies(); - }, [loadReplies]); - useEffect(() => { - if (props.connection.addEventListener && message.mid) { - props.connection.addEventListener('msg', onReply); - } - return () => { - if (props.connection.removeEventListener && message.mid) { - props.connection.removeEventListener('msg', onReply); - } - }; - }, [props.connection, message.mid, onReply]); - let loadReplies = useCallback(() => { + let loadReplies = () => { document.body.scrollTop = 0; document.documentElement.scrollTop = 0; @@ -156,7 +138,7 @@ export default function Thread(props) { ).catch(ex => { console.log(ex); }); - }, [props.visitor, props.match.params]); + }; let onReply = useCallback((json) => { const msg = JSON.parse(json.data); if (msg.mid == message.mid) { @@ -165,6 +147,7 @@ export default function Thread(props) { }); } }, [message]); + let postComment = (template) => { const { mid, rid, body, attach } = template; let commentAction = editing.rid ? update(mid, editing.rid, body) : comment(mid, rid, body, attach); @@ -180,6 +163,21 @@ export default function Thread(props) { setEditing(reply); }; + useEffect(() => { + setActive(0); + loadReplies(); + }, []); + useEffect(() => { + if (props.connection.addEventListener && message.mid) { + props.connection.addEventListener('msg', onReply); + } + return () => { + if (props.connection.removeEventListener && message.mid) { + props.connection.removeEventListener('msg', onReply); + } + }; + }, [props.connection, message.mid, onReply]); + const loaders = Math.min(message.replies || 0, 10); return ( <> @@ -214,11 +212,11 @@ export default function Thread(props) { const linkStyle = { cursor: 'pointer' }; - +/* Thread.propTypes = { location: ReactRouterPropTypes.location, history: ReactRouterPropTypes.history, match: ReactRouterPropTypes.match, visitor: UserType.isRequired, connection: PropTypes.object.isRequired, -}; +};*/ diff --git a/vnext/src/ui/Types.js b/vnext/src/ui/Types.js deleted file mode 100644 index 9bf7b513..00000000 --- a/vnext/src/ui/Types.js +++ /dev/null @@ -1,15 +0,0 @@ -import PropTypes from 'prop-types'; - -export const UserType = PropTypes.shape({ - uid: PropTypes.number.isRequired, - uname: PropTypes.string, - avatar: PropTypes.string, - uri: PropTypes.string -}); - -export const MessageType = PropTypes.shape({ - mid: PropTypes.number, - user: UserType, - timestamp: PropTypes.string.isRequired, - body: PropTypes.string -}); diff --git a/vnext/src/ui/UploadButton.js b/vnext/src/ui/UploadButton.js index 73cbbfcf..28a1f340 100644 --- a/vnext/src/ui/UploadButton.js +++ b/vnext/src/ui/UploadButton.js @@ -1,8 +1,18 @@ import React from 'react'; -import PropTypes from 'prop-types'; import Icon from './Icon'; +/** + * @typedef {Object} UploadButtonProps + * @property {string} value + * @property {React.MutableRefObject<HTMLInputElement>} inputRef + * @property {function} onChange + */ + +/** + * Upload button + * @param {UploadButtonProps} props + */ export default function UploadButton(props) { let openfile = () => { const input = props.inputRef.current; @@ -12,6 +22,10 @@ export default function UploadButton(props) { input.click(); } }; + + /** + * @param {React.ChangeEvent<HTMLInputElement>} event + */ let attachmentChanged = (event) => { props.onChange(event.target.value); }; @@ -26,12 +40,6 @@ export default function UploadButton(props) { ); } -UploadButton.propTypes = { - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - inputRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }) -}; - const inactiveStyle = { cursor: 'pointer', color: '#888' diff --git a/vnext/src/ui/UserInfo.css b/vnext/src/ui/UserInfo.css index 92cfdb6c..3e692a86 100644 --- a/vnext/src/ui/UserInfo.css +++ b/vnext/src/ui/UserInfo.css @@ -4,8 +4,8 @@ margin: 12px; } .info-avatar img { - max-height: 24px; - max-width: 24px; + max-height: 36px; + max-width: 36px; padding: 6px; vertical-align: middle; } diff --git a/vnext/src/ui/UserInfo.js b/vnext/src/ui/UserInfo.js index 0d06d134..faa2ebd6 100644 --- a/vnext/src/ui/UserInfo.js +++ b/vnext/src/ui/UserInfo.js @@ -1,9 +1,6 @@ import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { UserType } from './Types'; - import { info, fetchUserUri } from '../api'; import Avatar from './Avatar'; @@ -14,8 +11,20 @@ import './UserInfo.css'; let isMounted; +/** + * Wrapper for dumb VSCode + * @param {import('../api').User} user + */ +function useUserState(user) { + return useState(user); +} + +/** + * User info component + * @param {{user: string, onUpdate?: function, children?: Element}} props + */ export default function UserInfo(props) { - const [user, setUser] = useState({ uname: props.user, uid: 0 }); + const [user, setUser] = useUserState({ uname: props.user, uid: 0 }); const { onUpdate } = props; useEffect(() => { isMounted = true; @@ -63,6 +72,10 @@ export default function UserInfo(props) { ); } +/** + * User summary component + * @param {{user: import('../api').User}} props + */ function Summary({ user }) { const readUrl = `/${user.uname}/friends`; const readersUrl = `/${user.uname}/readers`; @@ -78,12 +91,13 @@ function Summary({ user }) { ); } -Summary.propTypes = { - user: UserType.isRequired -}; - const UserSummary = React.memo(Summary); + +/** + * Link to user + * @param {{ user: import('../api').User}} props + */ export function UserLink(props) { const [user, setUser] = useState(props.user); useEffect(() => { @@ -105,13 +119,3 @@ export function UserLink(props) { : <a href={user.uri} className="info-avatar"><img src={user.avatar} />{user.uname}</a> ); } - -UserInfo.propTypes = { - user: PropTypes.string.isRequired, - onUpdate: PropTypes.func, - children: PropTypes.node -}; - -UserLink.propTypes = { - user: UserType.isRequired -}; diff --git a/vnext/src/ui/Users.js b/vnext/src/ui/Users.js index a10bba7f..4c09318f 100644 --- a/vnext/src/ui/Users.js +++ b/vnext/src/ui/Users.js @@ -1,18 +1,28 @@ import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import ReactRouterPropTypes from 'react-router-prop-types'; import UserInfo from './UserInfo'; import Avatar from './Avatar'; +/** + * Friends feed + * @param {{match: import('react-router').match }} match + */ export function Friends({ match }) { return <Users user={match.params.user} prop='read' />; } +/** + * Readers feed + * @param {{match: import('react-router').match }} match + */ export function Readers({ match }) { return <Users user={match.params.user} prop='readers' />; } +/** + * UserInfo list component + * @param {{user: import('../api').User, prop: string}} props + */ function Users(props) { const [user, setUser] = useState({ uid: 0, uname: props.user }); return ( @@ -28,17 +38,3 @@ function Users(props) { </UserInfo> ); } - - -Friends.propTypes = { - match: ReactRouterPropTypes.match.isRequired -}; - -Readers.propTypes = { - match: ReactRouterPropTypes.match.isRequired -}; - -Users.propTypes = { - user: PropTypes.string.isRequired, - prop: PropTypes.string.isRequired -}; diff --git a/vnext/src/ui/helpers/BubbleStyle.js b/vnext/src/ui/helpers/BubbleStyle.js index f44f726e..f8b0f4eb 100644 --- a/vnext/src/ui/helpers/BubbleStyle.js +++ b/vnext/src/ui/helpers/BubbleStyle.js @@ -1,3 +1,8 @@ +/** + * @param {import('../../api').User} me + * @param {import('../../api').Message} msg + * @returns {React.CSSProperties} + */ export function bubbleStyle(me, msg) { const isMe = me.uid === msg.user.uid; const color = isMe ? '#fff' : '#222'; @@ -10,6 +15,11 @@ export function bubbleStyle(me, msg) { }; } +/** + * @param {import('../../api').User} me + * @param {import('../../api').Message} msg + * @returns {React.CSSProperties} + */ export function chatItemStyle(me, msg) { const isMe = me.uid === msg.user.uid; const alignment = isMe ? 'flex-end' : 'flex-start'; diff --git a/vnext/src/utils/embed.js b/vnext/src/utils/embed.js index 6a92521a..be96d036 100644 --- a/vnext/src/utils/embed.js +++ b/vnext/src/utils/embed.js @@ -63,7 +63,7 @@ function makeIframe(src, w, h, scrolling='no') { let iframe = document.createElement('iframe'); iframe.style.width = w; iframe.style.height = h; - iframe.frameBorder = 0; + iframe.frameBorder = '0'; iframe.scrolling = scrolling; iframe.setAttribute('allowFullScreen', ''); iframe.src = src; |