diff options
Diffstat (limited to 'vnext/src/ui')
39 files changed, 2498 insertions, 0 deletions
diff --git a/vnext/src/ui/Avatar.css b/vnext/src/ui/Avatar.css new file mode 100644 index 00000000..7bdb3115 --- /dev/null +++ b/vnext/src/ui/Avatar.css @@ -0,0 +1,27 @@ +.Avatar { + display: flex; + width: 100%; +} +.msg-avatar { + max-height: 48px; + margin-right: 10px; + max-width: 48px; +} +.msg-avatar img { + max-height: 48px; + vertical-align: top; + max-width: 48px; +} + +.info-avatar img { + max-height: 24px; + max-width: 24px; + padding: 6px; + vertical-align: middle; +} + +@media screen and (min-width: 450px) { + .Avatar { + width: 300px; + } +} diff --git a/vnext/src/ui/Avatar.js b/vnext/src/ui/Avatar.js new file mode 100644 index 00000000..dda5449f --- /dev/null +++ b/vnext/src/ui/Avatar.js @@ -0,0 +1,44 @@ +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}) { + return ( + <div className="Avatar" style={style}> + <div className="msg-avatar"> + { + user.uname ? + <Link to={{ pathname: link || `/${user.uname}/` }}> + {user.avatar ? + <img src={user.avatar} alt={`${user.uname}`} /> + : <Icon name="ei-spinner" size="m" />} + </Link> + : <Icon name="ei-spinner" size="m" /> + } + </div> + <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> + <span> + <Link to={{ pathname: `/${user.uname}/` }}> + <span>{user.uname}</span> + </Link> + </span> + {children} + </div> + </div> + ); +} + +export default memo(Avatar); + +Avatar.propTypes = { + user: UserType, + link: PropTypes.string, + style: PropTypes.object, + children: PropTypes.node +}; diff --git a/vnext/src/ui/Button.css b/vnext/src/ui/Button.css new file mode 100644 index 00000000..2acb87be --- /dev/null +++ b/vnext/src/ui/Button.css @@ -0,0 +1,13 @@ +.Button { + background: #fff; + border: 1px solid #eee; + color: #888; + cursor: pointer; + display: inline-block; + margin: 5px; + padding: 4px 10px; +} +.Button:hover { + background: #f8f8f8; + border-bottom: 1px solid #ff339a; +} diff --git a/vnext/src/ui/Button.js b/vnext/src/ui/Button.js new file mode 100644 index 00000000..18cab0a7 --- /dev/null +++ b/vnext/src/ui/Button.js @@ -0,0 +1,11 @@ +import React from 'react'; + +import './Button.css'; + +function Button(props) { + return ( + <button className="Button" {...props} /> + ); +} + +export default React.memo(Button); diff --git a/vnext/src/ui/Chat.css b/vnext/src/ui/Chat.css new file mode 100644 index 00000000..520a5c9b --- /dev/null +++ b/vnext/src/ui/Chat.css @@ -0,0 +1,9 @@ +.Chat_messages { + box-sizing: border-box; + padding: 0 20px; + overflow-y: auto; + height: 450px; + display: flex; + flex-direction: column-reverse; + width: 100%; +} diff --git a/vnext/src/ui/Chat.js b/vnext/src/ui/Chat.js new file mode 100644 index 00000000..a1254a10 --- /dev/null +++ b/vnext/src/ui/Chat.js @@ -0,0 +1,85 @@ +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'; +import MessageInput from './MessageInput'; +import UserInfo from './UserInfo'; + +import { getChat, pm } from '../api'; + +import './Chat.css'; + +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; + setChats([]); + if (hash && uname) { + getChat(uname) + .then(response => { + setChats(response.data); + }); + } + }, [props.visitor]); + + let onMessage = useCallback((json) => { + const msg = JSON.parse(json.data); + if (msg.user.uname === props.match.params.user) { + setChats((oldChat) => { + return [msg, ...oldChat]; + }); + } + }, [props.match.params.user]); + + let onSend = (template) => { + pm(template.to.uname, template.body) + .then(res => { + loadChat(props.match.params.user); + }).catch(console.log); + }; + const uname = props.match.params.user; + return ( + <div className="msg-cont"> + <UserInfo user={uname} /> + {uname ? ( + <div className="chatroom"> + <ul className="Chat_messages"> + { + chats.map((chat) => + <PM key={moment.utc(chat.timestamp).valueOf()} chat={chat} {...props} /> + ) + } + </ul> + <MessageInput data={{ mid: 0, timestamp: '0', to: { uname: uname } }} onSend={onSend}> + Reply... + </MessageInput> + </div> + ) : ( + <div className="chatroom no-selection"><p>No chat selected</p></div> + ) + } + </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 new file mode 100644 index 00000000..24aabe15 --- /dev/null +++ b/vnext/src/ui/Contact.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { UserType } from './Types'; + +import Avatar from './Avatar'; + +function Contact({ user, style, ...rest }) { + return ( + <Avatar user={user} link={`/pm/${user.uname}`} style={style}> + {user.unreadCount && <span className="badge">{user.unreadCount}</span>} + <div className="msg-ts">{user.lastMessageText}</div> + </Avatar> + ); +} + +export default React.memo(Contact); + +Contact.propTypes = { + user: UserType, + style: PropTypes.object +}; diff --git a/vnext/src/ui/Contacts.js b/vnext/src/ui/Contacts.js new file mode 100644 index 00000000..3852b26f --- /dev/null +++ b/vnext/src/ui/Contacts.js @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; + +import { getChats } from '../api'; + + +import Contact from './Contact.js'; +import { ChatSpinner } from './Spinner'; + +export default function Contacts(props) { + const [pms, setPms] = useState([]); + useEffect(() => { + getChats() + .then(response => { + setPms(response.data.pms); + }); + }, []); + return ( + <div className="msg-cont"> + <div style={chatListStyle}> + { + pms.length ? pms.map((chat) => + <Contact key={chat.uname} user={chat} style={chatTitleStyle} /> + ) : <><ChatSpinner /><ChatSpinner /><ChatSpinner /><ChatSpinner /><ChatSpinner /></> + } + </div> + </div> + ); +} + +const wrapperStyle = { + display: 'flex', + backgroundColor: '#fff' +}; + +const chatListStyle = { + display: 'flex', + flexDirection: 'column', + width: '100%', + padding: '12px' +}; + +const chatTitleStyle = { + width: '100%', + padding: '12px', + textAlign: 'left', + background: '#fff', + color: '#222', + borderBottom: '1px solid #eee' +}; diff --git a/vnext/src/ui/Feeds.js b/vnext/src/ui/Feeds.js new file mode 100644 index 00000000..c7b857b7 --- /dev/null +++ b/vnext/src/ui/Feeds.js @@ -0,0 +1,171 @@ +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'; + +import Message from './Message'; +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)); + const query = { + baseUrl: '/api/messages', + search: search, + pageParam: search.search ? 'page' : 'before_mid' + }; + return (<Feed authRequired={false} query={query} {...props} />); +} + +export function Discussions(props) { + const query = { + baseUrl: '/api/messages/discussions', + pageParam: 'to' + }; + return (<Feed authRequired={false} query={query} {...props} />); +} + +export function Blog(props) { + const { user } = props.match.params; + let search = qs.parse(props.location.search.substring(1)); + search.uname = user; + const query = { + baseUrl: '/api/messages', + search: search, + pageParam: search.search ? 'page' : 'before_mid' + }; + return ( + <> + <div className="msg-cont"> + <UserInfo user={user} /> + </div> + <Feed authRequired={false} query={query} {...props} /> + </> + ); +} + +export function Tag(props) { + const { tag } = props.match.params; + const query = { + baseUrl: '/api/messages', + search: { + tag: tag + }, + pageParam: 'before_mid' + }; + return (<Feed authRequired={false} query={query} {...props} />); +} + +export function Home(props) { + const query = { + baseUrl: '/api/home', + pageParam: 'before_mid' + }; + return (<Feed authRequired={true} query={query} {...props} />); +} + +function Feed(props) { + const [msgs, setMsgs] = useState([]); + const [loading, setLoading] = useState(true); + const [nextpage, setNextpage] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let loadMessages = (hash = '', filter = '') => { + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + setMsgs([]); + const filterParams = qs.parse(filter); + let params = Object.assign({}, filterParams || {}, props.query.search || {}); + let url = props.query.baseUrl; + if (hash) { + params.hash = hash; + } + if (!params.hash && props.authRequired) { + props.history.push('/'); + } + getMessages(url, params) + .then(response => { + const { data } = response; + const { pageParam } = props.query; + const lastMessage = data.slice(-1)[0] || {}; + const nextpage = getPageParam(pageParam, lastMessage, filterParams); + setMsgs(data); + setLoading(false); + setNextpage(nextpage); + }).catch(ex => { + setError(true); + }); + }; + loadMessages(props.visitor.hash, props.location.search.substring(1)); + }, [props]); + + let getPageParam = (pageParam, lastMessage, filterParams) => { + const pageValue = pageParam === 'before_mid' ? lastMessage.mid : pageParam === 'page' ? (Number(filterParams.page) || 0) + 1 : moment.utc(lastMessage.updated).valueOf(); + let newFilter = { ...filterParams }; + newFilter[pageParam] = pageValue; + return `?${qs.stringify(newFilter)}`; + }; + const { tag } = qs.parse(location.search.substring(1) || {}); + const nodes = ( + <> + { + tag && ( + <p className="page"> + <Link to={{ pathname: `/tag/${tag}` }}> + <span>← All posts with tag </span><b>{tag}</b> + </Link> + </p> + ) + } + { + msgs.map(msg => + <Message key={msg.mid} data={msg} visitor={props.visitor} />) + } + { + msgs.length >= 20 && ( + <p className="page"> + <Link to={{ pathname: props.location.pathname, search: nextpage }} rel="prev">Next →</Link> + </p> + ) + } + </> + ); + return msgs.length > 0 ? ( + <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 +}; + +Blog.propTypes = { + match: ReactRouterPropTypes.match.isRequired, + location: ReactRouterPropTypes.location.isRequired, + search: PropTypes.string +}; + +Tag.propTypes = { + match: ReactRouterPropTypes.match.isRequired +}; + +Feed.propTypes = { + authRequired: PropTypes.bool, + visitor: UserType, + history: ReactRouterPropTypes.history.isRequired, + location: ReactRouterPropTypes.location.isRequired, + msgs: PropTypes.array, + query: PropTypes.shape({ + baseUrl: PropTypes.string.isRequired, + search: PropTypes.object, + pageParam: PropTypes.string.isRequired + }) +}; diff --git a/vnext/src/ui/Header.js b/vnext/src/ui/Header.js new file mode 100644 index 00000000..48f89360 --- /dev/null +++ b/vnext/src/ui/Header.js @@ -0,0 +1,69 @@ +import React, { useEffect, useCallback, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + +const elClassHidden = 'header--hidden'; + +const header = document.getElementById('header'); +header.removeChild(document.getElementById('header_wrapper')); + +export default function Header({ children }) { + let dHeight = useRef(0); + let wHeight = useRef(0); + let wScrollCurrent = useRef(0); + let wScrollBefore = useRef(0); + let wScrollDiff = useRef(0); + + useEffect(() => { + window.addEventListener('scroll', () => (!window.requestAnimationFrame) + ? throttle(250, updateHeader) + : window.requestAnimationFrame(updateHeader), false); + }, [updateHeader]); + let throttle = (delay, fn) => { + var last, deferTimer; + return function () { + var context = this, args = arguments, now = +new Date; + if (last && now < last + delay) { + clearTimeout(deferTimer); + deferTimer = setTimeout( + function () { + last = now; + fn.apply(context, args); + }, + delay); + } else { + last = now; + fn.apply(context, args); + } + }; + }; + let updateHeader = useCallback(() => { + dHeight.current = document.body.offsetHeight; + wHeight.current = window.innerHeight; + wScrollCurrent.current = window.pageYOffset; + wScrollDiff.current = wScrollBefore.current - wScrollCurrent.current; + + 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)) { + // scrolled up; element slides in + header.classList.remove(elClassHidden); + } else if (wScrollDiff.current < 0) { + // scrolled down + if (wScrollCurrent.current + wHeight.current >= dHeight.current && header.classList.contains(elClassHidden)) { + // scrolled to the very bottom; element slides in + header.classList.remove(elClassHidden); + } else { + // scrolled down; element slides out + header.classList.add(elClassHidden); + } + } + wScrollBefore.current = wScrollCurrent.current; + }, []); + return ReactDOM.createPortal(children, header); +} + +Header.propTypes = { + children: PropTypes.node +}; diff --git a/vnext/src/ui/Icon.js b/vnext/src/ui/Icon.js new file mode 100644 index 00000000..faf1a704 --- /dev/null +++ b/vnext/src/ui/Icon.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Icon = React.memo(props => { + var size = props.size ? ' icon--' + props.size : ''; + var className = props.className ? ' ' + props.className : ''; + var klass = 'icon' + (!props.noFill ? ' icon--' + props.name : '') + size + className; + + var name = '#' + props.name + '-icon'; + var useTag = '<use xlink:href=' + name + ' />'; + var Icon = React.createElement('svg', { className: 'icon__cnt', dangerouslySetInnerHTML: { __html: useTag } }); + return React.createElement( + 'div', + { className: klass }, + wrapSpinner(Icon, klass) + ); +}); + +function wrapSpinner(Html, klass) { + if (klass.indexOf('spinner') > -1) { + return React.createElement( + 'div', + { className: 'icon__spinner' }, + Html + ); + } else { + return Html; + } +} + +export default Icon; + +Icon.propTypes = { + size: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + className: PropTypes.string, + noFill: PropTypes.bool +}; diff --git a/vnext/src/ui/Input.css b/vnext/src/ui/Input.css new file mode 100644 index 00000000..dbe55eae --- /dev/null +++ b/vnext/src/ui/Input.css @@ -0,0 +1,8 @@ +.input { + background: #FFF; + border: 1px solid #ccc; + outline: none !important; + padding: 4px; + -webkit-appearance: none; + border-radius: 0; +} diff --git a/vnext/src/ui/Input.js b/vnext/src/ui/Input.js new file mode 100644 index 00000000..c74d595d --- /dev/null +++ b/vnext/src/ui/Input.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import './Input.css'; + +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/LoginButton.js b/vnext/src/ui/LoginButton.js new file mode 100644 index 00000000..cd26252e --- /dev/null +++ b/vnext/src/ui/LoginButton.js @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import Icon from './Icon'; +import Modal from './Modal'; +import Button from './Button'; +import Input from './Input'; +import { useFormState } from 'react-use-form-state'; + +import { me, facebookLink, vkLink } from '../api'; + +function LoginButton({ onAuth, title }) { + const [open, setOpen] = useState(false); + const [formState, { text, password }] = useFormState(); + + let onToggle = (event) => { + if (event) { + event.preventDefault(); + } + setOpen(!open); + }; + let onSubmit = (event) => { + event.preventDefault(); + me(formState.values.username, formState.values.password) + .then(response => { + onToggle(); + onAuth(response); + } + ).catch(ex => { + console.log(ex); + }); + }; + return ( + <> + <a onClick={onToggle}> + <Icon name="ei-user" size="s" /> + <span className="desktop">{title}</span> + </a> + <Modal show={open} + onClose={onToggle}> + <div className="dialoglogin"> + <p>Please, introduce yourself:</p> + <div style={socialButtonsStyle}> + <a href={facebookLink()} style={facebookButtonStyle}> + <Icon name="ei-sc-facebook" size="s" noFill={true} />Log in + </a> + <a href={vkLink()} style={vkButtonStyle}> + <Icon name="ei-sc-vk" size="s" noFill={true} /> + Log in + </a> + </div> + <p>Already registered?</p> + <form onSubmit={onSubmit}> + <Input name="username" + placeholder="Username..." + value={formState.values.username} {...text('username')} /><br /> + <Input name="password" + placeholder="Password..." + value={formState.values.password} {...password('password')} /><br /> + <Button onClick={onSubmit}>OK</Button> + </form> + </div> + </Modal> + </> + ); +} + +LoginButton.propTypes = { + title: PropTypes.string.isRequired, + onAuth: PropTypes.func.isRequired +}; + +const socialButtonsStyle = { + display: 'flex', + justifyContent: 'space-evenly', + padding: '4px' +}; + +const facebookButtonStyle = { + color: '#fff', + padding: '2px 14px', + background: '#3b5998' +}; + +const vkButtonStyle = { + color: '#fff', + padding: '2px 14px', + background: '#4c75a3' +}; + +export default LoginButton; diff --git a/vnext/src/ui/Message.css b/vnext/src/ui/Message.css new file mode 100644 index 00000000..18d3d0d5 --- /dev/null +++ b/vnext/src/ui/Message.css @@ -0,0 +1,210 @@ +.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 #eee; + display: flex; + align-items: center; + justify-content: space-around; + background: #fdfdfe; +} +.msg-cont > .l a { + color: #88958d; + margin-right: 15px; + font-size: small; +} +.msg-tags { + color: #88958d; + margin-top: 12px; + min-height: 1px; +} +.badge, +.msg-tags > a { + color: #88958d; + display: inline-block; + font-size: small; +} +.msgthread { + margin-bottom: 0; +} +.msg-cont { + background: #FFF; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.16); + line-height: 140%; + margin-bottom: 12px; +} +.reply-new .msg-cont { + border-right: 5px solid #0C0; +} +.msg-ts { + font-size: small; + vertical-align: top; +} +.msg-ts, +.msg-ts > a { + color: #88958d; +} +.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-links { + color: #88958d; + font-size: small; + margin: 5px 0 0 0; + padding: 12px; +} +.msg-comments { + color: #88958d; + 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: #EEE; + border: 1px solid #CCC; + color: #999; + margin: 0 0 0 6px; + position: sticky; + top: 70px; + vertical-align: top; + width: 50px; +} +.msg-recomms { + color: #88958d; + background: #fdfdfe; + font-size: small; + margin-bottom: 10px; + padding: 6px; + border-bottom: 1px solid #eee; + overflow: hidden; + text-indent: 10px; +} +.msg-summary, +.msg-summary a { + color: #88958d; + font-size: small; + padding: 12px; + text-align: right; +} +#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; + margin: 30px -3px 15px -3px; +} +.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; +} diff --git a/vnext/src/ui/Message.js b/vnext/src/ui/Message.js new file mode 100644 index 00000000..eb008bfe --- /dev/null +++ b/vnext/src/ui/Message.js @@ -0,0 +1,142 @@ +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'; + +import { format, embedUrls } from '../utils/embed'; + +import './Message.css'; + +export default function Message({ data, visitor, children, ...rest }) { + const isCode = (data.tags || []).indexOf('code') >= 0; + const likesSummary = data.likes ? `${data.likes}` : 'Recommend'; + const commentsSummary = data.replies ? `${data.replies}` : 'Comment'; + const embedRef = useRef(); + const msgRef = useRef(); + useEffect(() => { + if (msgRef.current) { + embedUrls(msgRef.current.querySelectorAll('a'), embedRef.current); + if (!embedRef.current.hasChildNodes()) { + embedRef.current.style.display = 'none'; + } + } + }, []); + 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> + </div> + </Avatar> + <TagsList user={data.user} data={data.tags || []} /> + </header> + { + data.body && + <div className="msg-txt" ref={msgRef}> + <MessageContainer isCode={isCode} data={{ __html: format(data.body, data.mid, isCode) }} /> + </div> + } + { + data.photo && + <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> + </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"> + <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"> + <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> + )} + {!data.ReadOnly | (visitor.uid === data.user.uid) && ( + <> + <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> + </> + )} + </nav> + {children} + </div> + ); +} + +function MessageContainer({ isCode, data }) { + return isCode ? (<pre dangerouslySetInnerHTML={data} />) : (<p dangerouslySetInnerHTML={data} />); +} + +function Tags({ data, user, ...rest }) { + return data.length > 0 && ( + <div className="msg-tags"> + { + data.map(tag => { + return (<Link key={tag} to={{ pathname: `/${user.uname}`, search: `?tag=${tag}` }} title={tag}>{tag}</Link>); + }).reduce((prev, curr) => [prev, ', ', curr]) + } + </div> + ); +} + +const TagsList = React.memo(Tags); + +function Recommends({ forMessage, ...rest }) { + const { likes, recommendations } = forMessage; + 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]) + } + { + likes > recommendations.length && (<span> and {likes - recommendations.length} others</span>) + } + </div> + ) || null; +} + +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 new file mode 100644 index 00000000..e4988d59 --- /dev/null +++ b/vnext/src/ui/MessageInput.js @@ -0,0 +1,107 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; + +import { useFormState } from 'react-use-form-state'; + +import { MessageType } from './Types'; + +import Icon from './Icon'; +import Button from './Button'; + +import UploadButton from './UploadButton'; + +export default function MessageInput({ text, data, rows, children, onSend }) { + let textareaRef = useRef(); + let fileinput = useRef(); + + let updateFocus = () => { + const isDesktop = window.matchMedia('(min-width: 62.5rem)'); + if (isDesktop.matches) { + textareaRef.current.focus(); + } + }; + useEffect(() => { + textareaRef.current.value = text || ''; + updateFocus(); + }, [text]); + + let handleCtrlEnter = (event) => { + if (event.ctrlKey && (event.charCode == 10 || event.charCode == 13)) { + onSubmit({}); + } + }; + let textChanged = (event) => { + const el = textareaRef.current; + const offset = el.offsetHeight - el.clientHeight; + const height = el.scrollHeight + offset; + el.style.height = `${height + offset}px`; + }; + const [attach, setAttach] = useState(''); + const [formState, { textarea }] = useFormState(); + let uploadValueChanged = (attach) => { + setAttach(attach); + }; + let onSubmit = (event) => { + if (event.preventDefault) { + event.preventDefault(); + } + const input = fileinput.current; + onSend({ + mid: data.mid, + rid: data.rid || 0, + body: formState.values.body, + attach: attach ? input.files[0] : '', + to: data.to || {} + }); + setAttach(''); + formState.clearField('body'); + textareaRef.current.style.height = ''; + updateFocus(); + }; + return ( + <form className="msg-comment-target" style={{ padding: '12px' }} onSubmit={onSubmit}> + <div style={commentStyle}> + <textarea onChange={textChanged} onKeyPress={handleCtrlEnter} + ref={textareaRef} style={textInputStyle} value={formState.values.body} + rows={rows || '1'} placeholder={children} {...textarea('body')} /> + <div style={inputBarStyle}> + <UploadButton inputRef={fileinput} value={attach} onChange={uploadValueChanged} /> + <Button onClick={onSubmit}><Icon name="ei-envelope" size="s" />Send</Button> + </div> + </div> + </form> + ); +} + +const commentStyle = { + display: 'flex', + flexDirection: 'column', + borderTop: '1px #eee solid', + width: '100%', + marginTop: '10px' +}; + +const inputBarStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '3px' +}; + +const textInputStyle = { + overflow: 'hidden', + resize: 'none', + display: 'block', + boxSizing: 'border-box', + border: 0, + 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/Modal.css b/vnext/src/ui/Modal.css new file mode 100644 index 00000000..931b9d81 --- /dev/null +++ b/vnext/src/ui/Modal.css @@ -0,0 +1,95 @@ +#dialogt { + height: 100%; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.3); +} +#dialogw { + z-index: 11; + max-width: 96%; + max-height: calc(100% - 100px); + background-color: #fff; +} +#dialogw a { + display: block; +} +#dialogw img { + max-height: 100%; + max-height: 90vh; + max-width: 100%; +} +#dialog_header { + width: 100%; + height: 44px; + display: flex; + flex-direction: row-reverse; + align-items: center; +} +.header_image { + background: rgba(0, 0, 0, 0.28); +} +#dialogc { + cursor: pointer; + color: #ccc; + padding-right: 6px; +} +.dialoglogin { + background: #fff; + padding: 25px; +} +.dialog-opened { + overflow: hidden; +} +#signemail, +#signfb, +#signvk { + display: block; + line-height: 32px; + margin: 10px 0; + text-decoration: none; + width: 100%; +} +#signvk { + margin-bottom: 30px; +} +.dialoglogin form { + margin-top: 7px; +} +.signinput, +.signsubmit { + border: 1px solid #CCC; + margin: 3px 0; + padding: 3px; +} +.signsubmit { + width: 70px; +} +.dialogshare { + background: #fff; + min-width: 300px; + overflow: auto; + padding: 20px; +} +.dialogl { + background: #fff; + border: 1px solid #DDD; + margin: 3px 0 20px; + padding: 5px; +} +.dialogshare li { + float: left; + margin: 5px 10px 0 0; +} +.dialogshare a { + display: block; +} +.dialogtxt { + background: #fff; + padding: 20px; +} diff --git a/vnext/src/ui/Modal.js b/vnext/src/ui/Modal.js new file mode 100644 index 00000000..799a6f35 --- /dev/null +++ b/vnext/src/ui/Modal.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Icon from './Icon'; + +import './Modal.css'; + +function Modal(props) { + return props.show ? ( + <div id="dialogt"> + <div id="dialogw"> + <div id="dialog_header"> + <div id="dialogc" onClick={props.onClose}> + <Icon name="ei-close" size="s" /> + </div> + </div> + {props.children} + </div> + </div> + ) : (null); +} + +export default React.memo(Modal); + +Modal.propTypes = { + onClose: PropTypes.func.isRequired, + show: PropTypes.bool, + children: PropTypes.node +}; diff --git a/vnext/src/ui/NavigationIcon.css b/vnext/src/ui/NavigationIcon.css new file mode 100644 index 00000000..caff6195 --- /dev/null +++ b/vnext/src/ui/NavigationIcon.css @@ -0,0 +1,4 @@ +#navicon { + padding: 12px; + color: #88958d; +} diff --git a/vnext/src/ui/NavigationIcon.js b/vnext/src/ui/NavigationIcon.js new file mode 100644 index 00000000..0a22ac57 --- /dev/null +++ b/vnext/src/ui/NavigationIcon.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Icon from './Icon'; + +import './NavigationIcon.css'; + +function NavigationIcon(props) { + return ( + <div id="navicon" className="mobile" onClick={props.onToggle}> + <Icon name="ei-navicon" size="s"/> + </div> + ); +} + +export default React.memo(NavigationIcon); + +NavigationIcon.propTypes = { + onToggle: PropTypes.func.isRequired +}; + diff --git a/vnext/src/ui/PM.js b/vnext/src/ui/PM.js new file mode 100644 index 00000000..a1e70ad5 --- /dev/null +++ b/vnext/src/ui/PM.js @@ -0,0 +1,51 @@ +import React from 'react'; + +import { UserType, MessageType } from './Types'; + +import Avatar from './Avatar'; +import { format } from '../utils/embed'; + +function PM(props) { + const { chat } = props; + return ( + <li> + <div style={chatItemStyle(props.visitor, chat)}> + <Avatar user={chat.user} /> + <div style={bubbleStyle(props.visitor, chat)}> + <p dangerouslySetInnerHTML={{ __html: format(chat.body) }} /> + </div> + </div> + </li> + ); +} + +export default React.memo(PM); + +function bubbleStyle(me, msg) { + const isMe = me.uid === msg.user.uid; + const color = isMe ? '#fff' : '#222'; + const background = isMe ? '#ec4b98' : '#eee'; + return { + background: background, + color: color, + padding: '12px' + }; +} + +function chatItemStyle(me, msg) { + const isMe = me.uid === msg.user.uid; + const alignment = isMe ? 'flex-end' : 'flex-start'; + return { + padding: '3px 6px', + listStyle: 'none', + margin: '10px 0', + display: 'flex', + flexDirection: 'column', + alignItems: alignment + }; +} + +PM.propTypes = { + chat: MessageType.isRequired, + visitor: UserType.isRequired +}; diff --git a/vnext/src/ui/Post.js b/vnext/src/ui/Post.js new file mode 100644 index 00000000..3dc23613 --- /dev/null +++ b/vnext/src/ui/Post.js @@ -0,0 +1,38 @@ +import React, { memo } from 'react'; + +import ReactRouterPropTypes from 'react-router-prop-types'; +import { UserType } from './Types'; + +import qs from 'qs'; + +import MessageInput from './MessageInput'; + +import { post } from '../api'; + +function PostComponent(props) { + let params = qs.parse(window.location.search.substring(1)); + let postMessage = (template) => { + const { attach, body } = template; + post(body, attach) + .then(response => { + if (response.status === 200) { + const msg = response.data.newMessage; + this.props.history.push(`/${this.props.visitor.uname}/${msg.mid}`); + } + }).catch(console.log); + }; + return ( + <div className="msg-cont"> + <MessageInput rows="7" text={params.body || ''} data={{ mid: 0, timestamp: '0' }} onSend={postMessage}> + *weather It is very cold today! + </MessageInput> + </div> + ); +} + +export default memo(PostComponent); + +PostComponent.propTypes = { + history: ReactRouterPropTypes.history.isRequired, + visitor: UserType +}; diff --git a/vnext/src/ui/SearchBox.js b/vnext/src/ui/SearchBox.js new file mode 100644 index 00000000..a79100cd --- /dev/null +++ b/vnext/src/ui/SearchBox.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ReactRouterPropTypes from 'react-router-prop-types'; +import { withRouter } from 'react-router-dom'; +import { useFormState } from 'react-use-form-state'; + +function SearchBox({ onSearch, history, pathname }) { + let onSubmit = (event) => { + event.preventDefault(); + onSearch(history, pathname, formState.values.search); + }; + const [formState, { text }] = useFormState(); + return ( + <form onSubmit={onSubmit}> + <input name="search" className="text" + placeholder="Search..." value={ formState.values.search } {...text('search')} /> + </form> + ); +} + +SearchBox.propTypes = { + pathname: PropTypes.string.isRequired, + onSearch: PropTypes.func.isRequired, + history: ReactRouterPropTypes.history.isRequired +}; +export default withRouter(SearchBox); diff --git a/vnext/src/ui/Settings.js b/vnext/src/ui/Settings.js new file mode 100644 index 00000000..cf6926f8 --- /dev/null +++ b/vnext/src/ui/Settings.js @@ -0,0 +1,268 @@ +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 }) { + const [avatar, setAvatar] = useState(''); + const [preview, setPreview] = useState(); + const avatarInput = useRef(); + let avatarChanged = (newAvatar) => { + setAvatar(newAvatar); + setPreview(''); + if (newAvatar) { + let reader = new FileReader(); + reader.onloadend = (preview) => { + setPreview(preview.target.result); + }; + reader.readAsDataURL(avatarInput.current.files[0]); + } + }; + let previewUser = { ...visitor, uname: '<preview>' }; + if (preview) { + previewUser = { ...visitor, avatar: preview, uname: '<preview>' }; + } + let onSubmitAvatar = (event) => { + if (event.preventDefault) { + event.preventDefault(); + } + updateAvatar(avatarInput.current.files[0]).then(() => { + avatarChanged(''); + me().then(visitor => { + this.props.onChange(visitor); + }); + }); + }; + return ( + <form> + <small>Recommendations: PNG, 96x96, <50Kb. Also, JPG and GIF supported.</small> + <UploadButton inputRef={avatarInput} + value={avatar} onChange={avatarChanged} /> + <Avatar user={visitor} /> + <Avatar user={previewUser} /> + <Button onClick={onSubmitAvatar}>Update</Button> + </form> + ); +} + +ChangeAvatarForm.propTypes = { + visitor: UserType.isRequired +}; + +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) => { + 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); + console.log('email update'); + } + disableTelegram = () => { + console.log('telegram disable'); + } + disableFacebook = (event) => { + if (event.preventDefault) { + event.preventDefault(); + } + console.log('facebook disable'); + } + enableFacebook = (event) => { + if (event.preventDefault) { + event.preventDefault(); + } + console.log('facebook enable'); + } + disableTwitter = () => { + console.log('twitter disable'); + } + + 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> + <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>— + <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> + </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> + </form> + <form> + <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 /> + </React.Fragment> + ) : '-' + } + </p> + { + me.emails && me.emails.length > 1 && + <Button onClick={this.deleteEmail}>Delete</Button> + } + </form> + { + 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> + </> + } + </fieldset> + <fieldset> + <legend><Icon name="ei-sc-facebook" size="m" />Facebook</legend> + {me.facebookStatus && me.facebookStatus.connected ? ( + me.facebookStatus.crosspostEnabled ? + <form> + <div> + Facebook: <b>Enabled</b> — + <Button onClick={this.disableFacebook}>Disable</Button> + </div> + </form> + : + <form> + <div> + Facebook: <b>Disabled</b> — + <Button onClick={this.enableFacebook}>Enable</Button> + </div> + </form> + ) : ( + <p>Cross-posting to Facebook: + <a href="/_fblogin"> + <img src="//static.juick.com/facebook-connect.png" alt="Connect to Facebook" /> + </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> — + <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> + + </div> + ); + } +} + +Settings.propTypes = { + visitor: UserType.isRequired, + onChange: PropTypes.func.isRequired +}; + diff --git a/vnext/src/ui/Spinner.js b/vnext/src/ui/Spinner.js new file mode 100644 index 00000000..e866369f --- /dev/null +++ b/vnext/src/ui/Spinner.js @@ -0,0 +1,42 @@ +import React from 'react'; +import ContentLoader from 'react-content-loader'; + +function Spinner(props) { + return ( + <div className="msg-cont"> + <div className="msg-txt"> + <ContentLoader + speed={2} + primaryColor="#f8f8f8" + secondaryColor="#ecebeb" + {...props}> + <rect x="56" y="6" rx="0" ry="0" width="117" height="6.4" /> + <rect x="56" y="20" rx="0" ry="0" width="85" height="6.4" /> + <rect x="0" y="60" rx="0" ry="0" width="270" height="6.4" /> + <rect x="0" y="78" rx="0" ry="0" width="270" height="6.4" /> + <rect x="0" y="96" rx="0" ry="0" width="201" height="6.4" /> + <rect x="0" y="0" rx="0" ry="0" width="48" height="48" /> + <rect x="0" y="120" rx="0" ry="0" width="270" height="1" /> + </ContentLoader> + </div> + </div> + ); +} + +export default React.memo(Spinner); + +export function ChatSpinner(props) { + return ( + <ContentLoader + speed={2} + primaryColor="#f8f8f8" + secondaryColor="#ecebeb" + height="60px" + width="120px" + {...props}> + <rect x="56" y="6" rx="0" ry="0" width="117" height="6.4" /> + <rect x="56" y="20" rx="0" ry="0" width="85" height="6.4" /> + <rect x="0" y="0" rx="0" ry="0" width="48" height="48" /> + </ContentLoader> + ); +} diff --git a/vnext/src/ui/Thread.js b/vnext/src/ui/Thread.js new file mode 100644 index 00000000..e7ccb032 --- /dev/null +++ b/vnext/src/ui/Thread.js @@ -0,0 +1,181 @@ +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'; +import Spinner from './Spinner'; +import Avatar from './Avatar'; +import Button from './Button'; + +import { format, embedUrls } from '../utils/embed'; + +import { getMessages, comment, markReadTracker } from '../api'; + +function Comment({ msg, visitor, active, setActive, postComment }) { + const embedRef = useRef(); + const msgRef = useRef(); + useEffect(() => { + if (msgRef.current) { + embedUrls(msgRef.current.querySelectorAll('a'), embedRef.current); + if (!embedRef.current.hasChildNodes()) { + embedRef.current.style.display = 'none'; + } + } + }, []); + return ( + <div className="msg-cont"> + <div className="msg-header"> + <Avatar user={msg.user}> + <div className="msg-ts"> + {msg.replyto > 0 && + ( + <a href={`#${msg.replyto}`} className="info-avatar"><img src={msg.to.avatar} /> {msg.to.uname} </a> + )} + </div> + </Avatar> + </div> + { + msg.html ? <div className="msg-txt" dangerouslySetInnerHTML={{ __html: msg.body }} ref={msgRef} /> + : + <div className="msg-txt" ref={msgRef}> + <p dangerouslySetInnerHTML={{ __html: format(msg.body, msg.mid, (msg.tags || []).indexOf('code') >= 0) }} /> + </div> + } + { + msg.photo && + <div className="msg-media"> + <a href={`//i.juick.com/p/${msg.mid}-${msg.rid}.${msg.attach}`} data-fname={`${msg.mid}-${msg.rid}.${msg.attach}`}> + <img src={`//i.juick.com/p/${msg.mid}-${msg.rid}.${msg.attach}`} alt="" /> + </a> + </div> + } + <div className="embedContainer" ref={embedRef} /> + <div className="msg-links"> + { + visitor.uid > 0 ? ( + <> + {active === msg.rid || <span style={linkStyle} onClick={() => setActive(msg.rid)}>Reply</span>} + {active === msg.rid && <MessageInput data={msg} onSend={postComment}>Write a comment...</MessageInput>} + </> + ) : ( + <> + <span> · </span>{active === msg.rid || <Button>Reply</Button>} + </> + ) + } + </div> + </div> + ); +} + +Comment.propTypes = { + msg: MessageType.isRequired, + visitor: UserType.isRequired, + active: PropTypes.number.isRequired, + setActive: PropTypes.func.isRequired, + postComment: PropTypes.func.isRequired +}; + +export default function Thread(props) { + const [message, setMessage] = useState((props.location.state || {}).msg || {}); + const [replies, setReplies] = useState([]); + const [loading, setLoading] = useState(false); + const [active, setActive] = useState(0); + 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(() => { + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + + setReplies([]); + setLoading(true); + const { mid } = props.match.params; + let params = { + mid: mid + }; + if (props.visitor && props.visitor.hash) { + params.hash = props.visitor.hash; + } + getMessages('/api/thread', params) + .then(response => { + setMessage(response.data.shift()); + setReplies(response.data); + setLoading(false); + setActive(0); + } + ).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) { + setReplies(oldReplies => { + return [...oldReplies, msg]; + }); + } + }, [message]); + let postComment = (template) => { + const { mid, rid, body, attach } = template; + comment(mid, rid, body, attach).then(res => { + loadReplies(); + }) + .catch(console.log); + }; + + const loaders = Math.min(message.replies || 0, 10); + return ( + <> + { + message.mid ? ( + <Message data={message} visitor={props.visitor}> + {active === (message.rid || 0) && <MessageInput data={message} onSend={postComment}>Write a comment...</MessageInput>} + </Message> + ) : ( + <Spinner /> + ) + } + <ul id="replies"> + { + !loading ? replies.map((msg) => ( + <li id={msg.rid} key={msg.rid} className="msg"> + <Comment msg={msg} visitor={props.visitor} active={active} setActive={setActive} postComment={postComment} /> + </li> + )) : ( + <> + {Array(loaders).fill().map((it, i) => <Spinner key={i} />)} + </> + ) + } + </ul> + </> + ); +} + +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 new file mode 100644 index 00000000..9bf7b513 --- /dev/null +++ b/vnext/src/ui/Types.js @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000..73cbbfcf --- /dev/null +++ b/vnext/src/ui/UploadButton.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Icon from './Icon'; + +export default function UploadButton(props) { + let openfile = () => { + const input = props.inputRef.current; + if (props.value) { + props.onChange(''); + } else { + input.click(); + } + }; + let attachmentChanged = (event) => { + props.onChange(event.target.value); + }; + return ( + <div style={props.value ? activeStyle : inactiveStyle} + onClick={openfile}> + <Icon name="ei-camera" size="s" /> + <input type="file" accept="image/jpeg,image/png" onClick={e => e.stopPropagation()} + style={{ display: 'none' }} ref={props.inputRef} value={props.value} + onChange={attachmentChanged} /> + </div> + ); +} + +UploadButton.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + inputRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }) +}; + +const inactiveStyle = { + cursor: 'pointer', + color: '#888' +}; +const activeStyle = { + cursor: 'pointer', + color: 'green' +}; diff --git a/vnext/src/ui/UserInfo.css b/vnext/src/ui/UserInfo.css new file mode 100644 index 00000000..92cfdb6c --- /dev/null +++ b/vnext/src/ui/UserInfo.css @@ -0,0 +1,11 @@ +.userinfo { + padding: 40px; + background-color: #fdfdfe; + margin: 12px; +} +.info-avatar img { + max-height: 24px; + max-width: 24px; + padding: 6px; + vertical-align: middle; +} diff --git a/vnext/src/ui/UserInfo.js b/vnext/src/ui/UserInfo.js new file mode 100644 index 00000000..7d84488e --- /dev/null +++ b/vnext/src/ui/UserInfo.js @@ -0,0 +1,117 @@ +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'; +import Icon from './Icon'; +import SearchBox from './SearchBox'; + +import './UserInfo.css'; + +let isMounted; + +export default function UserInfo(props) { + const [user, setUser] = useState({ uname: props.user, uid: 0 }); + const { onUpdate } = props; + useEffect(() => { + isMounted = true; + if (!user.avatar) { + info(user.uname).then(response => { + if (isMounted) { + setUser(response.data); + onUpdate && onUpdate(response.data); + } + }); + } + return () => { + isMounted = false; + }; + }, [onUpdate, user, user.avatar]); + return ( + <> + <div className="userinfo"> + <Avatar user={user}> + <div className="msg-ts">Was online recently</div> + </Avatar> + </div> + <UserSummary user={user} /> + <div className="l"> + { + user.uid > 0 && + <> + <Link to={`/pm/${user.uname}`}> + <Icon name="ei-envelope" size="s" /> + <span className="desktop">PM</span> + </Link> + <Link to={`/${user.uname}/?show=recomm`} rel="nofollow"> + <Icon name="ei-heart" size="s" /> + <span className="desktop">Recommendations</span> + </Link> + <Link to={`/${user.uname}/?media=1`} rel="nofollow"> + <Icon name="ei-camera" size="s" /> + <span className="desktop">Photos</span> + </Link> + </> + } + </div> + {props.children} + </> + ); +} + +function Summary({ user }) { + const readUrl = `/${user.uname}/friends`; + const readersUrl = `/${user.uname}/readers`; + const blUrl = `/${user.uname}/bl`; + let read = user.read && <Link key={readUrl} to={readUrl}>I read: {user.read.length}</Link>; + let readers = user.readers && <Link key={readersUrl} to={readersUrl}>My readers: {user.readers.length}</Link>; + let mybl = user.statsMyBL && <Link key={blUrl} to={blUrl}>My blacklist: {user.statsMyBL}</Link>; + let presentItems = [read, readers, mybl].filter(Boolean); + return ( + <div className="msg-summary"> + {presentItems.length > 0 && presentItems.reduce((prev, curr) => [prev, ' ', curr])} + </div> + ); +} + +Summary.propTypes = { + user: UserType.isRequired +}; + +const UserSummary = React.memo(Summary); + +export function UserLink(props) { + const [user, setUser] = useState(props.user); + useEffect(() => { + isMounted = true; + if (!user.uid && user.uri) { + fetchUserUri(user.uri).then(response => { + if (isMounted) { + setUser({ ...response.data, uid: 66666666 }); + } + }); + } + return () => { + isMounted = false; + }; + }, [user.uid, user.uri]); + return ( + user.uid ? + <Link key={user.uid} to={`/${user.uname}/`} className="info-avatar"><img src={user.avatar} />{user.uname}</Link> + : <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 new file mode 100644 index 00000000..a10bba7f --- /dev/null +++ b/vnext/src/ui/Users.js @@ -0,0 +1,44 @@ +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'; + +export function Friends({ match }) { + return <Users user={match.params.user} prop='read' />; +} + +export function Readers({ match }) { + return <Users user={match.params.user} prop='readers' />; +} + +function Users(props) { + const [user, setUser] = useState({ uid: 0, uname: props.user }); + return ( + <UserInfo user={user.uname} onUpdate={setUser}> + <div style={{ display: 'flex', flexWrap: 'wrap', flexDirection: 'row' }}> + { + user[props.prop] && + user[props.prop].map(user => + <Avatar key={user.uid} user={user} /> + ) + } + </div> + </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/__tests__/Avatar.test.js b/vnext/src/ui/__tests__/Avatar.test.js new file mode 100644 index 00000000..e7221871 --- /dev/null +++ b/vnext/src/ui/__tests__/Avatar.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import Avatar from '../Avatar'; +import renderer from 'react-test-renderer'; + +test('Avatar renders correctly', () => { + const component = renderer.create( + <MemoryRouter> + <Avatar user={{ uid: 1, uname: 'ugnich', avatar: 'https://juick.com/i/a/1-deadbeef.png' }} /> + </MemoryRouter> + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/vnext/src/ui/__tests__/LoginButton.test.js b/vnext/src/ui/__tests__/LoginButton.test.js new file mode 100644 index 00000000..da80abb0 --- /dev/null +++ b/vnext/src/ui/__tests__/LoginButton.test.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import LoginButton from '../LoginButton'; +import { create, act } from 'react-test-renderer'; + +test('Login button and form are renders correctly', () => { + var button = null; + act(() => { + button = create( + <LoginButton title="Log in" onAuth={() => { }} /> + ); + }); + let link = button.toJSON(); + expect(link).toMatchSnapshot(); + + act(() => { + button.root.findByType('a').props.onClick(); + }); + let modal = button.toJSON(); + expect(modal).toMatchSnapshot(); +}); diff --git a/vnext/src/ui/__tests__/MessageInput-test.js b/vnext/src/ui/__tests__/MessageInput-test.js new file mode 100644 index 00000000..7ac69ed0 --- /dev/null +++ b/vnext/src/ui/__tests__/MessageInput-test.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { create, act } from 'react-test-renderer'; + +import MessageInput from '../MessageInput'; + +const testMessage = { + mid: 1, + rid: 0, + body: 'test message', + timestamp: new Date().toISOString(), + attach: '', + to: {} +}; + +window.matchMedia = window.matchMedia || function () { + return { + matches: true, + addListener: function () { }, + removeListener: function () { } + }; +}; + +it('Gives immediate focus on to textarea on load', () => { + let focused = false; + act(() => { + create(<MessageInput data={testMessage} onSend={() => { }} />, { + createNodeMock: (element) => { + if (element.type === 'textarea') { + // mock a focus function + return { + focus: () => { + focused = true; + }, + style: {} + }; + } + return null; + } + }); + }); + expect(focused).toEqual(true, 'textarea was not focused'); +}); + + +it('Submits on ctrl-enter', () => { + const onSend = jest.fn(); + var messageInput = null; + act(() => { + messageInput = create(<MessageInput data={testMessage} onSend={onSend} />, { + createNodeMock: (element) => { + if (element.type === 'textarea') { + return { + focus: () => { }, + style: {} + }; + } + return null; + } + }); + }); + let textarea = messageInput.root.findByType('textarea'); + act(() => { + + textarea.props.onKeyPress({ + charCode: 13, + which: 13, + keyCode: 13, + ctrlKey: false + }); + }); + expect(onSend).toHaveBeenCalledTimes(0); + act(() => { + textarea.props.onKeyPress({ + charCode: 13, + which: 13, + keyCode: 13, + ctrlKey: true + }); + }); + expect(onSend).toHaveBeenCalledTimes(1); + expect(textarea.props.value).toEqual(''); + act(() => { + textarea.props.onChange({ + target: { + value: ' ', + validity: {} + } + }); + }); + expect(textarea.props.value).toEqual(' '); + act(() => { + messageInput.root.findByType('form').props.onSubmit({ event: {} }); + }); + expect(textarea.props.value).toEqual('', 'Value should be cleared after submit'); +}); diff --git a/vnext/src/ui/__tests__/UserLink.test.js b/vnext/src/ui/__tests__/UserLink.test.js new file mode 100644 index 00000000..a75344b0 --- /dev/null +++ b/vnext/src/ui/__tests__/UserLink.test.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { MemoryRouter, Switch, Route } from 'react-router-dom'; + +import { UserLink } from '../UserInfo'; +import renderer from 'react-test-renderer'; + +test('UserLink renders correctly', async () => { + const component = renderer.create( + <MemoryRouter> + <> + <UserLink user={{ uid: 1, uname: 'ugnich', avatar: 'https://juick.com/i/a/1-deadbeef.png' }} /> + <UserLink user={{ uid: 1, uname: 'ugnich', avatar: 'https://juick.com/i/a/1-deadbeef.png', uri: '' }} /> + <UserLink user={{ uid: 0, uname: '', uri: 'https://example.com/u/test' }} /> + </> + </MemoryRouter> + ); + await Promise.resolve(); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/vnext/src/ui/__tests__/__snapshots__/Avatar.test.js.snap b/vnext/src/ui/__tests__/__snapshots__/Avatar.test.js.snap new file mode 100644 index 00000000..47614f5a --- /dev/null +++ b/vnext/src/ui/__tests__/__snapshots__/Avatar.test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Avatar renders correctly 1`] = ` +<div + className="Avatar" +> + <div + className="msg-avatar" + > + <a + href="/ugnich/" + onClick={[Function]} + > + <img + alt="ugnich" + src="https://juick.com/i/a/1-deadbeef.png" + /> + </a> + </div> + <div + style={ + Object { + "display": "flex", + "flexDirection": "column", + "justifyContent": "center", + } + } + > + <span> + <a + href="/ugnich/" + onClick={[Function]} + > + <span> + ugnich + </span> + </a> + </span> + </div> +</div> +`; diff --git a/vnext/src/ui/__tests__/__snapshots__/LoginButton.test.js.snap b/vnext/src/ui/__tests__/__snapshots__/LoginButton.test.js.snap new file mode 100644 index 00000000..cd08b1b4 --- /dev/null +++ b/vnext/src/ui/__tests__/__snapshots__/LoginButton.test.js.snap @@ -0,0 +1,178 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Login button and form are renders correctly 1`] = ` +<a + onClick={[Function]} +> + <div + className="icon icon--ei-user icon--s" + > + <svg + className="icon__cnt" + dangerouslySetInnerHTML={ + Object { + "__html": "<use xlink:href=#ei-user-icon />", + } + } + /> + </div> + <span + className="desktop" + > + Log in + </span> +</a> +`; + +exports[`Login button and form are renders correctly 2`] = ` +Array [ + <a + onClick={[Function]} + > + <div + className="icon icon--ei-user icon--s" + > + <svg + className="icon__cnt" + dangerouslySetInnerHTML={ + Object { + "__html": "<use xlink:href=#ei-user-icon />", + } + } + /> + </div> + <span + className="desktop" + > + Log in + </span> + </a>, + <div + id="dialogt" + > + <div + id="dialogw" + > + <div + id="dialog_header" + > + <div + id="dialogc" + onClick={[Function]} + > + <div + className="icon icon--ei-close icon--s" + > + <svg + className="icon__cnt" + dangerouslySetInnerHTML={ + Object { + "__html": "<use xlink:href=#ei-close-icon />", + } + } + /> + </div> + </div> + </div> + <div + className="dialoglogin" + > + <p> + Please, introduce yourself: + </p> + <div + style={ + Object { + "display": "flex", + "justifyContent": "space-evenly", + "padding": "4px", + } + } + > + <a + href="https://juick.com/api/_fblogin?state=http://localhost/" + style={ + Object { + "background": "#3b5998", + "color": "#fff", + "padding": "2px 14px", + } + } + > + <div + className="icon icon--s" + > + <svg + className="icon__cnt" + dangerouslySetInnerHTML={ + Object { + "__html": "<use xlink:href=#ei-sc-facebook-icon />", + } + } + /> + </div> + Log in + </a> + <a + href="https://juick.com/api/_vklogin?state=http://localhost/" + style={ + Object { + "background": "#4c75a3", + "color": "#fff", + "padding": "2px 14px", + } + } + > + <div + className="icon icon--s" + > + <svg + className="icon__cnt" + dangerouslySetInnerHTML={ + Object { + "__html": "<use xlink:href=#ei-sc-vk-icon />", + } + } + /> + </div> + Log in + </a> + </div> + <p> + Already registered? + </p> + <form + onSubmit={[Function]} + > + <input + className="input" + name="username" + onBlur={[Function]} + onChange={[Function]} + placeholder="Username..." + type="text" + value="" + /> + <br /> + <input + className="input" + name="password" + onBlur={[Function]} + onChange={[Function]} + placeholder="Password..." + type="password" + value="" + /> + <br /> + <button + className="Button" + onClick={[Function]} + > + OK + </button> + </form> + </div> + </div> + </div>, +] +`; diff --git a/vnext/src/ui/__tests__/__snapshots__/UserLink.test.js.snap b/vnext/src/ui/__tests__/__snapshots__/UserLink.test.js.snap new file mode 100644 index 00000000..15e25367 --- /dev/null +++ b/vnext/src/ui/__tests__/__snapshots__/UserLink.test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UserLink renders correctly 1`] = ` +Array [ + <a + className="info-avatar" + href="/ugnich/" + onClick={[Function]} + > + <img + src="https://juick.com/i/a/1-deadbeef.png" + /> + ugnich + </a>, + <a + className="info-avatar" + href="/ugnich/" + onClick={[Function]} + > + <img + src="https://juick.com/i/a/1-deadbeef.png" + /> + ugnich + </a>, + <a + className="info-avatar" + href="https://example.com/u/test" + > + <img /> + + </a>, +] +`; |