aboutsummaryrefslogtreecommitdiff
path: root/vnext/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'vnext/src/ui')
-rw-r--r--vnext/src/ui/Avatar.css27
-rw-r--r--vnext/src/ui/Avatar.js44
-rw-r--r--vnext/src/ui/Button.css13
-rw-r--r--vnext/src/ui/Button.js11
-rw-r--r--vnext/src/ui/Chat.css9
-rw-r--r--vnext/src/ui/Chat.js85
-rw-r--r--vnext/src/ui/Contact.js21
-rw-r--r--vnext/src/ui/Contacts.js49
-rw-r--r--vnext/src/ui/Feeds.js171
-rw-r--r--vnext/src/ui/Header.js69
-rw-r--r--vnext/src/ui/Icon.js38
-rw-r--r--vnext/src/ui/Input.css8
-rw-r--r--vnext/src/ui/Input.js17
-rw-r--r--vnext/src/ui/LoginButton.js90
-rw-r--r--vnext/src/ui/Message.css210
-rw-r--r--vnext/src/ui/Message.js142
-rw-r--r--vnext/src/ui/MessageInput.js107
-rw-r--r--vnext/src/ui/Modal.css95
-rw-r--r--vnext/src/ui/Modal.js29
-rw-r--r--vnext/src/ui/NavigationIcon.css4
-rw-r--r--vnext/src/ui/NavigationIcon.js21
-rw-r--r--vnext/src/ui/PM.js51
-rw-r--r--vnext/src/ui/Post.js38
-rw-r--r--vnext/src/ui/SearchBox.js26
-rw-r--r--vnext/src/ui/Settings.js268
-rw-r--r--vnext/src/ui/Spinner.js42
-rw-r--r--vnext/src/ui/Thread.js181
-rw-r--r--vnext/src/ui/Types.js15
-rw-r--r--vnext/src/ui/UploadButton.js42
-rw-r--r--vnext/src/ui/UserInfo.css11
-rw-r--r--vnext/src/ui/UserInfo.js117
-rw-r--r--vnext/src/ui/Users.js44
-rw-r--r--vnext/src/ui/__tests__/Avatar.test.js15
-rw-r--r--vnext/src/ui/__tests__/LoginButton.test.js21
-rw-r--r--vnext/src/ui/__tests__/MessageInput-test.js95
-rw-r--r--vnext/src/ui/__tests__/UserLink.test.js20
-rw-r--r--vnext/src/ui/__tests__/__snapshots__/Avatar.test.js.snap41
-rw-r--r--vnext/src/ui/__tests__/__snapshots__/LoginButton.test.js.snap178
-rw-r--r--vnext/src/ui/__tests__/__snapshots__/UserLink.test.js.snap33
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&nbsp;</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>&nbsp;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, &lt;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>&mdash;
+ <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>&nbsp;</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> &mdash;
+ <Button onClick={this.disableFacebook}>Disable</Button>
+ </div>
+ </form>
+ :
+ <form>
+ <div>
+ Facebook: <b>Disabled</b> &mdash;
+ <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> &mdash;
+ <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}&nbsp;</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>&nbsp;&middot;&nbsp;</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>,
+]
+`;