From f470636a70943a8ecad8bddc791a1c2dddd28e1e Mon Sep 17 00:00:00 2001
From: Vitaly Takmazov
Date: Sat, 4 May 2019 21:13:12 +0300
Subject: Components -> UI
---
vnext/src/ui/Avatar.css | 27 +++
vnext/src/ui/Avatar.js | 44 ++++
vnext/src/ui/Button.css | 13 +
vnext/src/ui/Button.js | 11 +
vnext/src/ui/Chat.css | 9 +
vnext/src/ui/Chat.js | 85 +++++++
vnext/src/ui/Contact.js | 21 ++
vnext/src/ui/Contacts.js | 49 ++++
vnext/src/ui/Feeds.js | 171 +++++++++++++
vnext/src/ui/Header.js | 69 ++++++
vnext/src/ui/Icon.js | 38 +++
vnext/src/ui/Input.css | 8 +
vnext/src/ui/Input.js | 17 ++
vnext/src/ui/LoginButton.js | 90 +++++++
vnext/src/ui/Message.css | 210 ++++++++++++++++
vnext/src/ui/Message.js | 142 +++++++++++
vnext/src/ui/MessageInput.js | 107 ++++++++
vnext/src/ui/Modal.css | 95 ++++++++
vnext/src/ui/Modal.js | 29 +++
vnext/src/ui/NavigationIcon.css | 4 +
vnext/src/ui/NavigationIcon.js | 21 ++
vnext/src/ui/PM.js | 51 ++++
vnext/src/ui/Post.js | 38 +++
vnext/src/ui/SearchBox.js | 26 ++
vnext/src/ui/Settings.js | 268 +++++++++++++++++++++
vnext/src/ui/Spinner.js | 42 ++++
vnext/src/ui/Thread.js | 181 ++++++++++++++
vnext/src/ui/Types.js | 15 ++
vnext/src/ui/UploadButton.js | 42 ++++
vnext/src/ui/UserInfo.css | 11 +
vnext/src/ui/UserInfo.js | 117 +++++++++
vnext/src/ui/Users.js | 44 ++++
vnext/src/ui/__tests__/Avatar.test.js | 15 ++
vnext/src/ui/__tests__/LoginButton.test.js | 21 ++
vnext/src/ui/__tests__/MessageInput-test.js | 95 ++++++++
vnext/src/ui/__tests__/UserLink.test.js | 20 ++
.../ui/__tests__/__snapshots__/Avatar.test.js.snap | 41 ++++
.../__snapshots__/LoginButton.test.js.snap | 178 ++++++++++++++
.../__tests__/__snapshots__/UserLink.test.js.snap | 33 +++
39 files changed, 2498 insertions(+)
create mode 100644 vnext/src/ui/Avatar.css
create mode 100644 vnext/src/ui/Avatar.js
create mode 100644 vnext/src/ui/Button.css
create mode 100644 vnext/src/ui/Button.js
create mode 100644 vnext/src/ui/Chat.css
create mode 100644 vnext/src/ui/Chat.js
create mode 100644 vnext/src/ui/Contact.js
create mode 100644 vnext/src/ui/Contacts.js
create mode 100644 vnext/src/ui/Feeds.js
create mode 100644 vnext/src/ui/Header.js
create mode 100644 vnext/src/ui/Icon.js
create mode 100644 vnext/src/ui/Input.css
create mode 100644 vnext/src/ui/Input.js
create mode 100644 vnext/src/ui/LoginButton.js
create mode 100644 vnext/src/ui/Message.css
create mode 100644 vnext/src/ui/Message.js
create mode 100644 vnext/src/ui/MessageInput.js
create mode 100644 vnext/src/ui/Modal.css
create mode 100644 vnext/src/ui/Modal.js
create mode 100644 vnext/src/ui/NavigationIcon.css
create mode 100644 vnext/src/ui/NavigationIcon.js
create mode 100644 vnext/src/ui/PM.js
create mode 100644 vnext/src/ui/Post.js
create mode 100644 vnext/src/ui/SearchBox.js
create mode 100644 vnext/src/ui/Settings.js
create mode 100644 vnext/src/ui/Spinner.js
create mode 100644 vnext/src/ui/Thread.js
create mode 100644 vnext/src/ui/Types.js
create mode 100644 vnext/src/ui/UploadButton.js
create mode 100644 vnext/src/ui/UserInfo.css
create mode 100644 vnext/src/ui/UserInfo.js
create mode 100644 vnext/src/ui/Users.js
create mode 100644 vnext/src/ui/__tests__/Avatar.test.js
create mode 100644 vnext/src/ui/__tests__/LoginButton.test.js
create mode 100644 vnext/src/ui/__tests__/MessageInput-test.js
create mode 100644 vnext/src/ui/__tests__/UserLink.test.js
create mode 100644 vnext/src/ui/__tests__/__snapshots__/Avatar.test.js.snap
create mode 100644 vnext/src/ui/__tests__/__snapshots__/LoginButton.test.js.snap
create mode 100644 vnext/src/ui/__tests__/__snapshots__/UserLink.test.js.snap
(limited to 'vnext/src/ui')
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 (
+
+
+ {
+ user.uname ?
+
+ {user.avatar ?
+
+ :
}
+
+ :
+ }
+
+
+
+
+ {user.uname}
+
+
+ {children}
+
+
+ );
+}
+
+export default memo(Avatar);
+
+Avatar.propTypes = {
+ user: UserType,
+ link: PropTypes.string,
+ style: PropTypes.object,
+ children: PropTypes.node
+};
diff --git a/vnext/src/ui/Button.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 (
+
+ );
+}
+
+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 (
+
+
+ {uname ? (
+
+
+ {
+ chats.map((chat) =>
+
+ )
+ }
+
+
+ Reply...
+
+
+ ) : (
+
+ )
+ }
+
+ );
+}
+
+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 (
+
+ {user.unreadCount && {user.unreadCount}}
+ {user.lastMessageText}
+
+ );
+}
+
+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 (
+
+
+ {
+ pms.length ? pms.map((chat) =>
+
+ ) : <>>
+ }
+
+
+ );
+}
+
+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 ();
+}
+
+export function Discussions(props) {
+ const query = {
+ baseUrl: '/api/messages/discussions',
+ pageParam: 'to'
+ };
+ return ();
+}
+
+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 (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export function Tag(props) {
+ const { tag } = props.match.params;
+ const query = {
+ baseUrl: '/api/messages',
+ search: {
+ tag: tag
+ },
+ pageParam: 'before_mid'
+ };
+ return ();
+}
+
+export function Home(props) {
+ const query = {
+ baseUrl: '/api/home',
+ pageParam: 'before_mid'
+ };
+ return ();
+}
+
+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 && (
+
+
+ ← All posts with tag {tag}
+
+
+ )
+ }
+ {
+ msgs.map(msg =>
+ )
+ }
+ {
+ msgs.length >= 20 && (
+
+ Next →
+
+ )
+ }
+ >
+ );
+ return msgs.length > 0 ? (
+ {nodes}
+ ) : error ? error
: loading ?
: No more messages
;
+}
+
+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 = '';
+ 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.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 (
+ <>
+
+
+ {title}
+
+
+
+
Please, introduce yourself:
+
+
Already registered?
+
+
+
+ >
+ );
+}
+
+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 (
+
+
+
+ {
+ data.body &&
+
+
+
+ }
+ {
+ data.photo &&
+
+ }
+
+
+ {children}
+
+ );
+}
+
+function MessageContainer({ isCode, data }) {
+ return isCode ? () : ();
+}
+
+function Tags({ data, user, ...rest }) {
+ return data.length > 0 && (
+
+ {
+ data.map(tag => {
+ return ({tag});
+ }).reduce((prev, curr) => [prev, ', ', curr])
+ }
+
+ );
+}
+
+const TagsList = React.memo(Tags);
+
+function Recommends({ forMessage, ...rest }) {
+ const { likes, recommendations } = forMessage;
+ return recommendations && recommendations.length > 0 && (
+ {'♡ by '}
+ {
+ recommendations.map(it => (
+
+ )).reduce((prev, curr) => [prev, ', ', curr])
+ }
+ {
+ likes > recommendations.length && ( and {likes - recommendations.length} others)
+ }
+
+ ) || 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 (
+
+ );
+}
+
+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 ? (
+
+
+
+ {props.children}
+
+
+ ) : (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 (
+
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+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 (
+
+
+ *weather It is very cold today!
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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: '' };
+ if (preview) {
+ previewUser = { ...visitor, avatar: preview, uname: '' };
+ }
+ let onSubmitAvatar = (event) => {
+ if (event.preventDefault) {
+ event.preventDefault();
+ }
+ updateAvatar(avatarInput.current.files[0]).then(() => {
+ avatarChanged('');
+ me().then(visitor => {
+ this.props.onChange(visitor);
+ });
+ });
+ };
+ return (
+
+ );
+}
+
+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 (
+
+
+
+
+ {me.jids && (
+
+ )}
+
+
+
+
+
+ );
+ }
+}
+
+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 (
+
+ );
+}
+
+export default React.memo(Spinner);
+
+export function ChatSpinner(props) {
+ return (
+
+
+
+
+
+ );
+}
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 (
+
+
+ {
+ msg.html ?
+ :
+
+ }
+ {
+ msg.photo &&
+
+ }
+
+
+ {
+ visitor.uid > 0 ? (
+ <>
+ {active === msg.rid || setActive(msg.rid)}>Reply}
+ {active === msg.rid && Write a comment...}
+ >
+ ) : (
+ <>
+ · {active === msg.rid || }
+ >
+ )
+ }
+
+
+ );
+}
+
+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 ? (
+
+ {active === (message.rid || 0) && Write a comment...}
+
+ ) : (
+
+ )
+ }
+
+ {
+ !loading ? replies.map((msg) => (
+ -
+
+
+ )) : (
+ <>
+ {Array(loaders).fill().map((it, i) => )}
+ >
+ )
+ }
+
+ >
+ );
+}
+
+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 (
+
+
+ e.stopPropagation()}
+ style={{ display: 'none' }} ref={props.inputRef} value={props.value}
+ onChange={attachmentChanged} />
+
+ );
+}
+
+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 (
+ <>
+
+
+ Was online recently
+
+
+
+
+ {
+ user.uid > 0 &&
+ <>
+
+
+ PM
+
+
+
+ Recommendations
+
+
+
+ Photos
+
+ >
+ }
+
+ {props.children}
+ >
+ );
+}
+
+function Summary({ user }) {
+ const readUrl = `/${user.uname}/friends`;
+ const readersUrl = `/${user.uname}/readers`;
+ const blUrl = `/${user.uname}/bl`;
+ let read = user.read && I read: {user.read.length};
+ let readers = user.readers && My readers: {user.readers.length};
+ let mybl = user.statsMyBL && My blacklist: {user.statsMyBL};
+ let presentItems = [read, readers, mybl].filter(Boolean);
+ return (
+
+ {presentItems.length > 0 && presentItems.reduce((prev, curr) => [prev, ' ', curr])}
+
+ );
+}
+
+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 ?
+ {user.uname}
+ : {user.uname}
+ );
+}
+
+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 ;
+}
+
+export function Readers({ match }) {
+ return ;
+}
+
+function Users(props) {
+ const [user, setUser] = useState({ uid: 0, uname: props.user });
+ return (
+
+
+ {
+ user[props.prop] &&
+ user[props.prop].map(user =>
+
+ )
+ }
+
+
+ );
+}
+
+
+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(
+
+
+
+ );
+ 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(
+ { }} />
+ );
+ });
+ 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( { }} />, {
+ 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(, {
+ 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(
+
+ <>
+
+
+
+ >
+
+ );
+ 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`] = `
+
+`;
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`] = `
+
+
+ ",
+ }
+ }
+ />
+
+
+ Log in
+
+
+`;
+
+exports[`Login button and form are renders correctly 2`] = `
+Array [
+
+
+ ",
+ }
+ }
+ />
+
+
+ Log in
+
+ ,
+
+
+
+
+
+ Please, introduce yourself:
+
+
+
+ Already registered?
+
+
+
+
+
,
+]
+`;
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 [
+
+
+ ugnich
+ ,
+
+
+ ugnich
+ ,
+
+
+
+ ,
+]
+`;
--
cgit v1.2.3