initial add status-js + Murmur + ui components

This commit is contained in:
Barry Gitarts 2019-01-30 16:14:00 -05:00
parent 5d1164e87f
commit 2d2e23743a
32 changed files with 36383 additions and 140 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
SKIP_PREFLIGHT_CHECK=true

27965
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,35 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@areknawo/rex": "^2.0.0",
"@material-ui/core": "^3.5.1",
"@material-ui/icons": "^3.0.1",
"@types/jest": "^23.3.13",
"@types/node": "^10.12.19",
"@types/react": "^16.7.22",
"@types/react-dom": "^16.0.11",
"autoscroll-react": "^3.2.0",
"emoji-mart": "^2.8.1",
"eth-keyring-controller": "^3.3.1",
"formik": "^1.3.1",
"ipfs": "^0.33.1",
"lodash": "^4.17.11",
"memoize-one": "^4.0.3",
"murmur-client": "^0.2.0",
"react": "^16.7.0", "react": "^16.7.0",
"react-dom": "^16.7.0", "react-dom": "^16.7.0",
"react-scripts": "2.1.3" "react-dropzone": "^7.0.1",
"react-hot-loader": "^4.3.4",
"react-jazzicon": "^0.1.3",
"react-linkify": "^0.2.2",
"react-scripts": "2.1.3",
"react-spinners": "^0.4.7",
"react-spotify-player": "^1.0.4",
"react-syntax-highlighter": "^10.0.1",
"react-youtube": "^7.8.0",
"status-js-api": "^1.1.7",
"typescript": "^3.2.4",
"uuid": "^3.3.2"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@ -1,27 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import Home from './components/Home'
import logo from './logo.svg'; import logo from './logo.svg';
import './App.css'; import './App.css';
class App extends Component { class App extends Component {
render() { render() {
return ( return <Home />
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
} }
} }

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import { ChatContext } from '../context';
const ChannelBox = ({ channelName, message }) => (
<ChatContext.Consumer>
{({ setActiveChannel, currentChannel, channels }) =>
<ListItem onClick={() => setActiveChannel(channelName)} selected={currentChannel == channelName} style={{"cursor": "pointer", "padding": "0px 2px"}}>
<ListItemText primary={
<span style={{"color": "white"}}>
{channels[channelName].username ? `${channels[channelName].username}` : `#${channelName}`}
</span>
} secondary={message} />
</ListItem>
}
</ChatContext.Consumer>
);
export default ChannelBox;

View File

@ -0,0 +1,31 @@
// @flow
import React, { Fragment } from 'react';
import List from '@material-ui/core/List';
import ChannelBox from './ChannelBox';
import { isContactCode } from '../utils/parsers';
const ChannelBoxes = ({ channels }) => (
<div style={{ marginBottom: '50%' }}>
{channels.map((channel) => (
<Fragment key={channel}>
<ChannelBox channelName={channel} />
</Fragment>
))}
</div>
)
const ChannelList = ({ channels }) => {
const channelList = Object.keys(channels)
const onlyChannels = channelList.filter((i) => !isContactCode(i));
const directMessages = channelList.filter(isContactCode);
return (
<List>
<ChannelBoxes channels={onlyChannels} />
<span style={{ color: 'lightgray' }}>Direct Messages</span>
<ChannelBoxes channels={directMessages} />
</List>
)
}
export default ChannelList;

137
src/components/ChatBox.js Normal file
View File

@ -0,0 +1,137 @@
// @flow
import React, { Fragment, PureComponent } from 'react';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemText from '@material-ui/core/ListItemText';
import Avatar from '@material-ui/core/Avatar';
import YouTube from 'react-youtube';
import Linkify from 'react-linkify';
import SpotifyPlayer from 'react-spotify-player';
import { Emoji } from 'emoji-mart';
import Jazzicon, { jsNumberForAddress } from 'react-jazzicon'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { atomDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { Matcher } from '@areknawo/rex'
import SyntaxLookup from '../utils/syntaxLookup';
import { getFile } from '../utils/ipfs';
const ipfsMatcher = new Matcher().begin().find('/ipfs/');
// TODO: not exactly bulletproof right now, needs proper regex
function hasYoutubeLink(text) {
return text.indexOf('http://www.youtube.com') >= 0 || text.indexOf('https://www.youtube.com') >= 0;
}
// TODO: not exactly bulletproof right now, needs proper regex
function isSpotifyLink(text) {
return text.indexOf('spotify:') >= 0 ;
}
// https://gist.github.com/takien/4077195#
function getYoutubeId(url) {
let ID = '';
url = url.replace(/(>|<)/gi,'').split(/(vi\/|v=|\/v\/|youtu\.be\/|\/embed\/)/);
if (url[2] !== undefined) {
ID = url[2].split(/[^0-9a-z_\-]/i);
ID = ID[0];
}
else {
ID = url;
}
return ID;
}
function isImage(text) {
return text.indexOf("http") >= 0 && (text.indexOf('.jpg') || text.indexOf('.gif'));
}
// TODO: this needs to be reviewed. best to return as a css background-image instead
function displayImage(text) {
let reg = new RegExp(/\b(https?:\/\/\S+(?:png|jpe?g|gif)\S*)\b/);
let imageUrl = reg.exec(text);
if (!imageUrl) return (<span></span>);
return (<img src={imageUrl[0]} style={{maxWidth: '90%'}} />)
}
// TODO use regex for code parsing / detection. Add new line detection for shift+enter
const MessageRender = ({ message }) => {
const emojis = [];
let match;
const regex1 = RegExp(/:[\-a-zA-Z_+0-9]+:/g);
while ((match = regex1.exec(message)) !== null) {
emojis.push(<Emoji emoji={match[0]} size={16} />);
}
const parts = message.split(regex1);
parts.forEach((part, i) => {
parts[i] = <span className="match" key={i}>{part}{emojis[i]}</span>;
});
return (message[2] === "`" && SyntaxLookup[message.slice(0,2)]
? <SyntaxHighlighter language={SyntaxLookup[message.slice(0,2)]} style={atomDark}>{message.slice(3)}</SyntaxHighlighter>
: <Linkify><span style={{ wordWrap: 'break-word', whiteSpace: 'pre-line' }}>{parts}</span></Linkify>)
};
class ChatBox extends PureComponent {
state = {
imgUrl: null
};
componentDidMount() {
const { message } = this.props;
if (ipfsMatcher.test(message)) this.getImageFromIpfs();
}
getImageFromIpfs = async () => {
const { ipfs, message } = this.props;
const files = await getFile(ipfs, message);
const { content } = files[0];
const arrayBufferView = new Uint8Array(content);
const blob = new Blob([ arrayBufferView ], { type: "image/jpeg" });
const imgUrl = URL.createObjectURL(blob);
this.setState({ imgUrl });
};
render() {
const { username, message, pubkey } = this.props;
const { imgUrl } = this.state;
return (
<Fragment>
<ListItem>
<Avatar>
<ListItemAvatar>
<Avatar>
{pubkey && <Jazzicon diameter={40} seed={jsNumberForAddress(pubkey)}/>}
</Avatar>
</ListItemAvatar>
</Avatar>
<ListItemText primary={`${username}`} secondary={<MessageRender message={message}/>}/>
</ListItem>
{hasYoutubeLink(message) &&
<ListItem>
<YouTube
videoId={getYoutubeId(message)}
opts={{ height: '390', width: '640', playerVars: { autoplay: 0 } }}
/>
</ListItem>
}
{isSpotifyLink(message) &&
<ListItem>
<SpotifyPlayer
uri={message}
size={{ 'width': 300, 'height': 300 }}
view='list'
theme='black'
/>
</ListItem>
}
{!!imgUrl && <img src={imgUrl} alt='ipfs' style={{maxWidth: '90%'}} />}
{isImage(message) && displayImage(message)}
</Fragment>
);
};
}
export default ChatBox;

View File

@ -0,0 +1,95 @@
import React, { PureComponent } from 'react';
import CardContent from '@material-ui/core/CardContent';
import Typography from '@material-ui/core/Typography';
import grey from '@material-ui/core/colors/grey';
import PersonIcon from '@material-ui/icons/PersonOutline';
import Dialog from '@material-ui/core/Dialog';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemText from '@material-ui/core/ListItemText';
import DialogTitle from '@material-ui/core/DialogTitle';
import Avatar from '@material-ui/core/Avatar';
import CheckCircle from '@material-ui/icons/CheckCircle';
import OfflineBolt from '@material-ui/icons/OfflineBolt';
import Info from '@material-ui/icons/Info';
import Jazzicon, { jsNumberForAddress } from 'react-jazzicon'
import { ChatContext } from '../context';
class ChatHeader extends PureComponent {
state = {
displayChannelStats: false
}
componentDidMount() {
this.heartBeat();
}
componentWillUnmount() {
clearInterval(this.heartBeatId);
}
handleClose = () => {
this.setState({ displayChannelStats: false });
}
handleOpen = () => {
this.setState({ displayChannelStats: true });
}
heartBeat() {
this.heartBeatId = setInterval(() => { this.forceUpdate() }, 5000)
}
render() {
const { currentChannel, toggleSidebar } = this.props;
const { displayChannelStats } = this.state;
return (
<ChatContext.Consumer>
{({ channels }) => {
const channelUsers = channels[currentChannel].users;
const usersList = Object.keys(channelUsers);
const currentTime = new Date().getTime();
const userOffline = user => currentTime - user.lastSeen > 10*1000
return (
<div>
{channels[currentChannel].users && <Dialog onClose={this.handleClose} aria-labelledby="simple-dialog-title" open={displayChannelStats}>
<DialogTitle>{`Users Online in #${currentChannel}`}</DialogTitle>
<div>
<List>
{usersList.map(user => (
<ListItem button key={channelUsers[user].pubkey}>
{userOffline(channelUsers[user]) ? <OfflineBolt style={{ color: 'red' }} /> : <CheckCircle style={{ color: 'green' }} />}
<ListItemAvatar>
<Avatar>
<Jazzicon diameter={40} seed={jsNumberForAddress(channelUsers[user].pubkey)} />
</Avatar>
</ListItemAvatar>
<ListItemText primary={channelUsers[user].username} secondary={`Last seen on ${new Date(channelUsers[user].lastSeen)}`}/>
</ListItem>
))}
</List>
</div>
</Dialog>}
<CardContent style={{ flexBasis: '10%', paddingBottom: '0px' }}>
<Typography variant="h5" component="h2">
{channels[currentChannel].username ? `${channels[currentChannel].username}` : `#${currentChannel}`}
</Typography>
<div style={{ display: 'flex', alignItems: 'center' }}>
<PersonIcon style={{ color: grey[500] }} onClick={this.handleOpen}/><div style={{ color: grey[500] }}>{usersList.length}</div>
<span style={{ marginLeft: 'auto' }}>
<Info style={{ color: grey[500] }} onClick={toggleSidebar} />
</span>
</div>
</CardContent>
</div>
)
}
}
</ChatContext.Consumer>
)
}
}
export default ChatHeader;

215
src/components/ChatRoom.js Normal file
View File

@ -0,0 +1,215 @@
// @flow
import React, { Fragment, Component, PureComponent, createRef } from 'react';
import { Formik } from 'formik';
import autoscroll from 'autoscroll-react';
import List from '@material-ui/core/List';
import Divider from '@material-ui/core/Divider';
import Grid from '@material-ui/core/Grid';
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
import Dropzone from 'react-dropzone';
import { Picker } from 'emoji-mart';
import AddCircle from '@material-ui/icons/AddCircle'
import ChatBox from './ChatBox';
import ChatHeader from './ChatHeader';
import Userlist from './Userlist';
import { uploadFileAndSend } from '../utils/ipfs';
import 'emoji-mart/css/emoji-mart.css';
class WhoIsTyping extends PureComponent {
whoIsTyping() {
const { users, usersTyping, currentChannel } = this.props;
const currentTime = new Date().getTime();
const typingInChannel = usersTyping[currentChannel];
const typingUsers = [];
for (let pubkey in typingInChannel) {
const lastTyped = typingInChannel[pubkey];
if (!users[pubkey]) continue;
if (currentTime - lastTyped > 3*1000 || currentTime < lastTyped) continue;
typingUsers.push(users[pubkey].username)
}
return typingUsers;
}
render() {
const userList = this.whoIsTyping();
return (
<div style={{ textAlign: 'center' }}>
{!userList.length ? "" : `${userList.join(',')} is typing`}
</div>
)
}
}
function onDrop(acceptedFiles, rejectedFiles, ipfs, sendMessage) {
const file = acceptedFiles[0];
uploadFileAndSend(ipfs, file, sendMessage);
}
const keyDownHandler = (e, typingEvent, setValue, value) => {
if(e.shiftKey && e.keyCode === 13) {
e.preventDefault();
const cursor = e.target.selectionStart;
const newValue = `${value.slice(0, cursor)}\n${value.slice(cursor)}`;
setValue('chatInput', newValue);
}
else if (e.keyCode === 13) {
e.preventDefault();
const form = ChatRoomForm.current;
form.dispatchEvent(new Event("submit"));
}
typingEvent(e)
};
const AutoScrollList = autoscroll(List);
const formStyle = { display: 'flex', justifyContent: 'center', alignItems: 'center', flexBasis: '10%' };
const ChatRoomForm = createRef();
const NameInput = createRef();
const messagesOffset = 185;
class ChatRoom extends Component {
constructor(props) {
super(props);
this.state = {
showEmojis: false,
infoPanelActive: true
};
}
toggleEmojis(e) {
this.setState(({ showEmojis: !this.state.showEmojis }));
}
toggleInfoPanel = () => {
this.setState({ infoPanelActive: !this.state.infoPanelActive })
}
uploadFileDialog() {
this.fileInput.click();
}
fileChangedHandler(event) {
const { ipfs, sendMessage } = this.props;
const file = event.target.files[0];
console.dir("handling file upload");
uploadFileAndSend(ipfs, file, sendMessage);
}
addEmoji(emoji, chatInput, setValue) {
console.log(emoji);
setValue('chatInput', `${chatInput}:${emoji.id}:`);
this.setState(({showEmojis: false}), () => {
NameInput.current.labelNode.focus();
});
}
render() {
const { messages, sendMessage, currentChannel, usersTyping, typingEvent, channelUsers, allUsers, ipfs } = this.props;
const { showEmojis, infoPanelActive } = this.state;
const messagesHeight = `calc(100vh - ${messagesOffset}px)`;
return (
<div style={{ width: '100%', flexWrap: 'nowrap', display: 'flex', boxSizing: 'border-box' }} >
<input
type="file"
ref={(input) => { this.fileInput = input; }}
onChange={this.fileChangedHandler.bind(this)}
style={{display: 'none'}}
/>
<Grid xs={12} item>
<Dropzone
onDrop={(a, r) => {
onDrop(a, r, ipfs, sendMessage);
}}
disableClick
style={{ position: 'relative', height: '100%' }}
activeStyle={{
backgroundColor: 'grey',
outline: '5px dashed lightgrey',
alignSelf: 'center',
outlineOffset: '-10px'
}}>
<Grid
container
direction="column"
justify="flex-start"
alignItems="stretch"
style={{ height: '100%' }}
>
<ChatHeader currentChannel={currentChannel} toggleSidebar={this.toggleInfoPanel} />
<Divider/>
<Grid container wrap="nowrap">
<Grid xs={infoPanelActive ? 9 : 12} item style={{ overflowY: 'scroll' }}>
<AutoScrollList style={{ height: messagesHeight, overflow: 'scroll' }}>
{messages[currentChannel] && messages[currentChannel].map((message) => (
<Fragment key={message.data.payload}>
<ChatBox {...message} ipfs={ipfs}/>
<li>
<Divider/>
</li>
</Fragment>
))}
</AutoScrollList>
<Formik
initialValues={{ chatInput: '' }}
onSubmit={(values, { setSubmitting, resetForm }) => {
const { chatInput } = values;
sendMessage(chatInput);
resetForm();
setSubmitting(false);
}}
>
{({
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
setFieldValue
}) => (
<div className="chat-input">
<form onSubmit={handleSubmit} style={formStyle} ref={ChatRoomForm}>
<Button onClick={(e) => this.uploadFileDialog()}><AddCircle /></Button>
<TextField
id="chatInput"
ref={NameInput}
multiline
style={{ width: 'auto', flexGrow: '0.95', margin: '2px 0 0 0' }}
label="Type a message..."
type="text"
name="chatInput"
margin="normal"
variant="outlined"
fullWidth
onChange={handleChange}
onKeyDown={(e) => keyDownHandler(e, typingEvent, setFieldValue, values.chatInput)}
onBlur={handleBlur}
value={values.chatInput || ''}
/>
{showEmojis && <Picker onSelect={(emoji) => this.addEmoji(emoji, values.chatInput, setFieldValue)}
style={{ position: 'absolute', bottom: '80px', right: '20px' }}/>}
<Button onClick={(e) => this.toggleEmojis(e)}>Smile</Button>
{errors.chatInput && touched.chatInput && errors.chatInput}
</form>
<WhoIsTyping
currentChannel={currentChannel}
usersTyping={usersTyping}
users={allUsers}/>
</div>
)}
</Formik>
</Grid>
<Grid xs={infoPanelActive ? 3 : false} item style={{ overflow: 'auto', borderLeft: '1px solid lightgrey', minHeight: '100vh' }}>{infoPanelActive && <Userlist />}</Grid>
</Grid>
</Grid>
</Dropzone>
</Grid>
</div>
)
}
}
export default ChatRoom;

View File

@ -0,0 +1,94 @@
import React, { Fragment } from 'react';
import { Formik } from 'formik';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle';
import TextField from '@material-ui/core/TextField';
class ContextFilter extends React.Component {
state = {
open: false,
};
handleClickOpen = () => {
this.setState({ open: true });
};
handleClose = () => {
this.setState({ open: false });
};
render() {
const { open } = this.state;
const { joinConversation, name } = this.props;
return (
<Fragment>
<span onClick={this.handleClickOpen} style={{"color": "#CAC4C9", "cursor": "pointer"}}>
<span style={{"width": "90%", "display": "inline-block", "verticalAlign": "top"}}>{name}</span>
<span className="material-icons MuiIcon-root-4 Icons-icon-2" style={{"display": "inline-block", "position": "relative", "width": "21px"}} aria-hidden="true">add_circle2</span>
</span>
<Formik
initialValues={{ channel: '' }}
onSubmit={(values, { setSubmitting, resetForm }) => {
const { channel } = values;
joinConversation(channel);
resetForm();
setSubmitting(false);
this.handleClose();
}}
>
{({
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit
}) => (
<Dialog
open={open}
onClose={this.handleClose}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title">Join Conversation</DialogTitle>
<DialogContent>
<DialogContentText>
Enter the Channel, Contact Code or Username you would like to join
</DialogContentText>
<form onSubmit={handleSubmit}>
<TextField
autoFocus
id="channel"
name="channel"
variant="outlined"
margin="dense"
label="Channel"
type="text"
fullWidth
onChange={handleChange}
onBlur={handleBlur}
value={values.channel || ''}
/>
{errors.channel && touched.channel && errors.channel}
</form>
</DialogContent>
<DialogActions>
<Button onClick={this.handleClose} color="primary">
Cancel
</Button>
<Button type="submit" onClick={handleSubmit} color="primary">
Join
</Button>
</DialogActions>
</Dialog>
)}
</Formik>
</Fragment>
);
}
}
export default ContextFilter;

View File

@ -0,0 +1,3 @@
.sidebar {
background-color: #4d394b;
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import ChannelList from './ChannelList';
import ContextFilter from './ContextFilter';
import styles from './ContextPanel.css';
const ContextPanel = ({ channels, joinConversation }) => (
<div className={styles.sidebar} style={{"backgroundColor": "#4d394b", "height": "100%", "padding": "16px", borderRight: '1px solid ghostwhite'}} >
<h3 style={{"color": "white"}}>Status</h3>
<ContextFilter name="Channels" joinConversation={joinConversation} />
<ChannelList channels={channels} />
</div>
);
export default ContextPanel;

View File

@ -0,0 +1,37 @@
.backButton {
position: absolute;
}
.counter {
position: absolute;
top: 30%;
left: 45%;
font-size: 10rem;
font-weight: bold;
letter-spacing: -0.025em;
}
.btnGroup {
position: relative;
top: 500px;
width: 480px;
margin: 0 auto;
}
.btn {
font-size: 1.6rem;
font-weight: bold;
background-color: #fff;
border-radius: 50%;
margin: 10px;
width: 100px;
height: 100px;
opacity: 0.7;
cursor: pointer;
font-family: Arial, Helvetica, Helvetica Neue, sans-serif;
}
.btn:hover {
color: white;
background-color: rgba(0, 0, 0, 0.5);
}

73
src/components/Counter.js Normal file
View File

@ -0,0 +1,73 @@
// @flow
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import styles from './Counter.css';
import routes from '../constants/routes';
type Props = {
increment: () => void,
incrementIfOdd: () => void,
incrementAsync: () => void,
decrement: () => void,
counter: number
};
export default class Counter extends Component<Props> {
props: Props;
render() {
const {
increment,
incrementIfOdd,
incrementAsync,
decrement,
counter
} = this.props;
return (
<div>
<div className={styles.backButton} data-tid="backButton">
<Link to={routes.HOME}>
<i className="fa fa-arrow-left fa-3x" />
</Link>
</div>
<div className={`counter ${styles.counter}`} data-tid="counter">
{counter}
</div>
<div className={styles.btnGroup}>
<button
className={styles.btn}
onClick={increment}
data-tclass="btn"
type="button"
>
<i className="fa fa-plus" />
</button>
<button
className={styles.btn}
onClick={decrement}
data-tclass="btn"
type="button"
>
<i className="fa fa-minus" />
</button>
<button
className={styles.btn}
onClick={incrementIfOdd}
data-tclass="btn"
type="button"
>
odd
</button>
<button
className={styles.btn}
onClick={() => incrementAsync()}
data-tclass="btn"
type="button"
>
async
</button>
</div>
</div>
);
}
}

14
src/components/Home.css Normal file
View File

@ -0,0 +1,14 @@
.container {
position: absolute;
top: 30%;
left: 10px;
text-align: center;
}
.container h2 {
font-size: 5rem;
}
.container a {
font-size: 1.4rem;
}

316
src/components/Home.js Normal file
View File

@ -0,0 +1,316 @@
// @flow
import React, { PureComponent, Fragment } from 'react';
import StatusJS from 'status-js-api';
import Murmur from 'murmur-client';
import IPFS from 'ipfs';
import uuid from 'uuid/v4';
import { isNil } from 'lodash';
import Grid from '@material-ui/core/Grid';
import ChatRoom from './ChatRoom';
import ContextPanel from './ContextPanel';
import Login from './Login';
import { User } from '../utils/actors';
import { ChatContext } from '../context';
import { isContactCode } from '../utils/parsers';
import { getKeyData, createVault, restoreVault, wipeVault } from '../utils/keyManagement';
import { FullScreenLoader } from './Loaders';
import { openBrowserWindow, addWindowEventListeners } from '../utils/windows';
const typingNotificationsTimestamp = {};
const DEFAULT_CHANNEL = "mytest";
const URL = "ws://localhost:8546";
const status = new StatusJS();
type Props = {};
export default class Home extends PureComponent<Props> {
props: Props;
state = {
messages: { [DEFAULT_CHANNEL]: [] },
users: {},
channels: {
[DEFAULT_CHANNEL]: { users: {} }
},
currentChannel: DEFAULT_CHANNEL,
usersTyping: { [DEFAULT_CHANNEL]: [] },
identity: {},
loading: false,
keyStore: getKeyData()
};
componentDidMount() {
this.connectMurMur();
this.ipfs = new IPFS();
}
componentWillUnmount() {
clearInterval(this.pingInterval);
this.ipfs.shutdown();
}
connect = async (account) => {
if (!account) {
this.setState({ loading: true });
status.connectToProvider(this.server.provider);
return this.onConnect();
}
this.keyringController.exportAccount(account)
.then(key => { status.connect(URL, `0x${key}`) })
.then(() => { this.onConnect() })
};
connectMurMur() {
this.server = new Murmur({
protocols: ["libp2p"],
signalServer: { host: '104.248.64.24', port: '9090', protocol: 'ws' },
bootnodes: []
});
this.server.start();
}
onConnect = () => {
const { currentChannel } = this.state;
this.joinChannel(currentChannel);
this.pingChannel();
this.createOnUserMessageHandler();
//TODO store ref to clear on componentWillUnmount
addWindowEventListeners(this.sendMessage);
setTimeout(() => {
this.getMyIdentities();
// Uncomment to test signing to status channels
//this.openBrowser('http://localhost:3000/sign-and-verify-message/sign');
}, 1500);
}
openBrowser = (url) => {
openBrowserWindow(url);
}
pingChannel = (channelName) => {
const { currentChannel } = this.state;
this.pingInterval = setInterval(() => {
status.sendJsonMessage(channelName || currentChannel, {type: "ping"});
}, 5 * 1000)
}
setupKeyringController = async (password, mnemonic) => {
const { keyStore } = this.state;
if (!keyStore) {
this.keyringController = await createVault(password, mnemonic);
} else {
try {
this.keyringController = await restoreVault(password);
} catch(err) {
throw err;
}
}
this.setState({ loading: true });
const accounts = await this.keyringController.getAccounts();
this.connect(accounts[0]);
}
wipeKeyStore = () => {
wipeVault();
this.setState({ keyStore: null });
}
setActiveChannel = channelName => {
this.setState({ currentChannel: channelName, });
}
joinConversation = contact => {
const { joinChannel, addDirectMessage } = this;
if (isContactCode(contact)) {
addDirectMessage(contact)
} else {
joinChannel(contact)
}
}
addDirectMessage = contactCode => {
status.addContact(contactCode, () => {
this.addConversationEntry(contactCode);
})
}
addConversationEntry = (code, changeChannel = true) => {
const { channels, currentChannel } = this.state;
this.setState({
currentChannel: changeChannel ? code : currentChannel,
channels: {
...channels,
[code]: { users: {} }
}
})
}
joinChannel = channelName => {
status.joinChat(channelName, () => {
this.addConversationEntry(channelName);
console.log(`joined channel ${channelName}`);
status.onMessage(channelName, (err, data) => {
const msg = JSON.parse(data.payload)[1][0];
if (JSON.parse(data.payload)[1][1] === 'content/json') {
return this.handleProtocolMessages(channelName, data);
}
const message = { username: data.username, message: msg, pubkey: data.data.sig, data };
this.setState((prevState) => {
const existing = prevState.messages[channelName];
return {
messages: {
...prevState.messages,
[channelName]: existing ? [ ...existing, message ] : [ message ]
}
}
})
});
this.pingChannel(channelName);
});
}
createOnUserMessageHandler = () => {
status.onUserMessage((err, res) => {
if (res) {
const payload = JSON.parse(res.payload);
const msg = payload[1][0];
const sender = res.data.sig;
const message = { username: res.username, message: msg, data: res };
this.setState((prevState) => {
const existing = prevState.messages[sender];
return {
messages: {
...prevState.messages,
[sender]: existing ? [ ...existing, message ] : [ message ]
},
channels: {
...prevState.channels,
[sender]: { username: res.username, users: {} }
}
}
})
}
});
}
sendMessage = message => {
const { currentChannel } = this.state;
status.sendMessage(currentChannel, message);
}
addUserToChannel = (channelName, user) => {
const { channels } = this.state;
const channel = { ...channels[channelName] };
channel.users[user.pubkey] = user;
this.setState({ channels: { ...channels, [channelName]: channel }});
}
getChannel = channelName => {
const { channels } = this.state;
return channels.find(c => c.name === channelName);
}
getMyIdentities = async () => {
const publicKey = await status.getPublicKey();
const username = await status.getUserName(publicKey);
this.setState({
identity: { publicKey, username },
loading: false
})
}
handleProtocolMessages = (channelName, data) => {
const { identity: { publicKey } } = this.state
const msg = JSON.parse(JSON.parse(data.payload)[1][0]);
const fromUser = data.data.sig;
if (msg.type === 'ping') {
const user = this.addOrUpdateUserKey(fromUser, data.username);
this.addUserToChannel(channelName, user);
}
if (msg.type === 'typing' && fromUser !== publicKey) {
this.setState(prevState => ({
usersTyping: {
...prevState.usersTyping,
[channelName]: {
[fromUser]: (new Date().getTime())
}
}
}))
}
}
addOrUpdateUserKey = (pubkey, username) => {
const user = new User(pubkey, username);
user.lastSeen = (new Date().getTime());
user.online = true;
this.setState(prevState => ({
users: {
...prevState.users,
[pubkey]: user
}
}))
return user;
}
typingEvent = () => {
const { currentChannel } = this.state;
const now = (new Date().getTime());
if (!typingNotificationsTimestamp[currentChannel]) {
typingNotificationsTimestamp[currentChannel] = { lastEvent: 0 }
}
if (typingNotificationsTimestamp[currentChannel].lastEvent === 0 || now - typingNotificationsTimestamp[currentChannel].lastEvent > 3*1000) {;
typingNotificationsTimestamp[currentChannel].lastEvent = now;
status.sendJsonMessage(currentChannel, {type: "typing"});
}
}
render() {
const { messages, channels, currentChannel, users, usersTyping, identity, loading, keyStore } = this.state;
const channelUsers = channels[currentChannel].users;
const { setActiveChannel, setupKeyringController, wipeKeyStore, connect, ipfs } = this;
const chatContext = { setActiveChannel, currentChannel, users, channels };
return (
<ChatContext.Provider value={chatContext}>
{loading
? <FullScreenLoader />
: <Fragment>
{!identity.publicKey
? <Login
connect={connect}
setupKeyringController={setupKeyringController}
keyStore={keyStore}
wipeKeyStore={wipeKeyStore} />
: <div style={{ width: '100%', flexWrap: 'nowrap', display: 'flex', boxSizing: 'border-box' }} >
<Grid item xs={3}>
{!isNil(channels) &&
<ContextPanel
channels={channels}
joinConversation={this.joinConversation} />}
</Grid>
<Grid item xs={9}>
<ChatRoom
messages={messages}
sendMessage={this.sendMessage}
currentChannel={currentChannel}
usersTyping={usersTyping}
typingEvent={this.typingEvent}
channelUsers={channelUsers}
allUsers={users}
ipfs={ipfs}
/>
</Grid>
</div>}
</Fragment>}
</ChatContext.Provider>
);
}
}

25
src/components/Loaders.js Normal file
View File

@ -0,0 +1,25 @@
import React from 'react';
import { css } from 'react-emotion';
import { BounceLoader, GridLoader } from 'react-spinners';
const containerStyle = { display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', margin: '0 30% 0 30%' };
export const FullScreenLoader = () => (
<div className='sweet-loading' style={containerStyle}>
<BounceLoader
sizeUnit={"px"}
size={150}
color={'#4A90E2'}
/>
</div>
)
export const FullScreenGridLoader = () => (
<div className='sweet-loading' style={containerStyle}>
<GridLoader
sizeUnit={"px"}
size={25}
color={'#4A90E2'}
/>
</div>
)

89
src/components/Login.js Normal file
View File

@ -0,0 +1,89 @@
import React from 'react';
import Grid from '@material-ui/core/Grid';
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
import { Formik } from 'formik';
import { func } from 'prop-types';
import { isNull } from 'lodash';
import StatusJSLogo from '../images/statusjs-logo';
const containerStyle = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-evenly',
height: '100vh',
width: '50%'
};
const Login = ({ setupKeyringController, keyStore, wipeKeyStore, connect }) => (
<Grid
container
justify="center"
alignItems="center"
direction="column"
style={{ height: '100%' }}
>
<Formik
initialValues={{ password: '', seed: '' }}
onSubmit={(values, { resetForm, setFieldError }) => {
const { password, seed } = values;
setupKeyringController(password, seed)
.catch(err => {
setFieldError("password", err.message)
});
resetForm();
}}
>
{({
values,
errors,
handleChange,
handleBlur,
handleSubmit
}) => (
<form onSubmit={handleSubmit} style={containerStyle}>
<StatusJSLogo />
{isNull(keyStore) && <TextField
id="seed"
type="text"
name="seed"
rows="4"
multiline
label="Enter your 12 word mnemonic"
variant="outlined"
fullWidth
value={values.seed}
onBlur={handleBlur}
onChange={handleChange}
/>}
<TextField
id="password"
type="password"
name="password"
label={isNull(keyStore) ? "Set your password" : "Enter your password to login"}
variant="outlined"
fullWidth
error={errors.password}
helperText={errors.password}
value={values.password}
onBlur={handleBlur}
onChange={handleChange}
/>
<Button size="large" variant="outlined" color="primary" onClick={() => connect()}>
USE A ONE TIME RANDOM ACCOUNT
</Button>
{!isNull(keyStore) && <Button size="large" variant="outlined" color="secondary" onClick={wipeKeyStore}>
RESET ACCOUNT
</Button>}
</form>
)}
</Formik>
</Grid>
);
Login.propTypes = {
setupKeyringController: func.isRequired,
wipeKeyStore: func.isRequired,
connect: func.isRequired
};
export default Login;

View File

@ -0,0 +1,82 @@
import React, { PureComponent } from 'react';
import blueGrey from '@material-ui/core/colors/blueGrey';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemText from '@material-ui/core/ListItemText';
import Avatar from '@material-ui/core/Avatar';
import FiberManualRecord from '@material-ui/icons/FiberManualRecord';
import FiberManualRecordOutlined from '@material-ui/icons/FiberManualRecordOutlined';
import Jazzicon, { jsNumberForAddress } from 'react-jazzicon'
import green from '@material-ui/core/colors/green';
import Tooltip from '@material-ui/core/Tooltip';
import { ChatContext } from '../context';
const online = green['500'];
const offline = blueGrey['500'];
const scrolling = { height: '100vh', overflow: 'scroll' };
const sortUsers = (channelUsers, allUsers) => Object.keys(channelUsers).sort((x,y) => {
const currentTime = (new Date().getTime());
const xIsOnline = ((currentTime - allUsers[x].lastSeen) > 10*1000) ? 1 : -1;
const yIsOnline = ((currentTime - allUsers[y].lastSeen) > 10*1000) ? 1 : -1;
if (xIsOnline > yIsOnline) return 1;
if (xIsOnline < yIsOnline) return -1;
if (x.username < y.username) return -1;
if (x.username > y.username) return 1;
return 0;
});
class Userlist extends PureComponent {
componentDidMount() {
this.heartBeat();
}
componentWillUnmount() {
clearInterval(this.heartBeatId);
}
heartBeat() {
this.heartBeatId = setInterval(() => { this.forceUpdate() }, 5000)
}
render() {
return (
<ChatContext.Consumer>
{({ channels, currentChannel, users }) => {
const channelUsers = channels[currentChannel].users;
const usersList = sortUsers(channelUsers, users);
const currentTime = new Date().getTime();
const userOffline = user => currentTime - user.lastSeen > 10*1000
return (
<div style={scrolling}>
<List style={scrolling}>
{usersList.map(user => (
<ListItem button key={channelUsers[user].pubkey} style={{ display: 'flex', paddingLeft: '5px' }}>
<div style={{ display: 'flex' }}>
{userOffline(channelUsers[user]) ? <FiberManualRecordOutlined style={{ color: offline, margin: 'auto' }} /> : <FiberManualRecord style={{ color: online, margin: 'auto' }} />}
<ListItemAvatar>
<Avatar>
<Jazzicon diameter={40} seed={jsNumberForAddress(channelUsers[user].pubkey)} />
</Avatar>
</ListItemAvatar>
</div>
<Tooltip title={`Last seen on ${new Date(channelUsers[user].lastSeen)}`} placement="top-start">
<ListItemText primary={channelUsers[user].username} />
</Tooltip>
</ListItem>
))}
</List>
</div>
)
}
}
</ChatContext.Consumer>
)
}
}
export default Userlist;

3
src/context.js Normal file
View File

@ -0,0 +1,3 @@
import React from 'react';
export const ChatContext = React.createContext('chat');

10
src/images/js-logo.js Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
const JSLogo = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 630 630">
<rect width="630" height="630" fill="#f7df1e"/>
<path d="m423.2 492.19c12.69 20.72 29.2 35.95 58.4 35.95 24.53 0 40.2-12.26 40.2-29.2 0-20.3-16.1-27.49-43.1-39.3l-14.8-6.35c-42.72-18.2-71.1-41-71.1-89.2 0-44.4 33.83-78.2 86.7-78.2 37.64 0 64.7 13.1 84.2 47.4l-46.1 29.6c-10.15-18.2-21.1-25.37-38.1-25.37-17.34 0-28.33 11-28.33 25.37 0 17.76 11 24.95 36.4 35.95l14.8 6.34c50.3 21.57 78.7 43.56 78.7 93 0 53.3-41.87 82.5-98.1 82.5-54.98 0-90.5-26.2-107.88-60.54zm-209.13 5.13c9.3 16.5 17.76 30.45 38.1 30.45 19.45 0 31.72-7.61 31.72-37.2v-201.3h59.2v202.1c0 61.3-35.94 89.2-88.4 89.2-47.4 0-74.85-24.53-88.81-54.075z"/>
</svg>
)
export default JSLogo;

View File

@ -0,0 +1,8 @@
import React from 'react';
const StatusLogo = () => (
<svg width="124" height="124" xmlns="http://www.w3.org/2000/svg">
<path d="M72.458 61.429c-7.431.427-12.088-1.299-19.52-.871a31.245 31.245 0 0 0-5.47.796C48.565 47.65 58.292 35.662 71.519 34.9c8.117-.467 16.23 4.53 16.67 12.642.433 7.973-5.664 13.307-15.73 13.886M52.503 89.46c-7.776.438-15.547-4.24-15.969-11.831-.415-7.462 5.427-12.454 15.07-12.996 7.118-.4 11.58 1.216 18.698.815a30.589 30.589 0 0 0 5.24-.745C74.493 77.528 65.175 88.748 52.503 89.46M62 .181C27.758.18 0 27.857 0 62s27.758 61.82 62 61.82c34.242 0 62-27.678 62-61.82C124 27.858 96.242.18 62 .18" fill="#4360DF" fillRule="evenodd"/></svg>
)
export default StatusLogo

View File

@ -0,0 +1,14 @@
import React from 'react';
import StatusLogo from './status-logo';
import JSLogo from './js-logo';
const StatusJSLogo = () => (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<StatusLogo />
<div style={{ width: '25%' }}>
<JSLogo />
</div>
</div>
)
export default StatusJSLogo;

0
src/utils/.gitkeep Normal file
View File

8
src/utils/actors.js Normal file
View File

@ -0,0 +1,8 @@
export class User {
constructor(pubkey, username) {
this.pubkey = pubkey;
this.username = username;
this.online = false;
this.lastSeen = 0;
}
}

28
src/utils/ipfs.js Normal file
View File

@ -0,0 +1,28 @@
import fs from 'fs';
export const fileUpload = (node, filePath) => {
const file = fs.readFileSync(filePath);
return new Promise(function(resolve, reject) {
node.files.add(file, (err, files) => {
if (err) return reject(err)
else resolve(files)
})
})
}
export const uploadFileAndSend = async (node, file, sendFn) => {
const { name, path, type } = file;
const files = await fileUpload(node, path);
const { hash } = files[0];
const text = `/ipfs/${hash}`;
sendFn(text);
}
export const getFile = (node, CID) => {
return new Promise(function(resolve, reject) {
node.files.get(CID, function (err, files) {
if (err) reject(err)
else resolve(files)
})
})
}

View File

@ -0,0 +1,26 @@
import KeyringController from 'eth-keyring-controller';
import Store from './store';
const store = new Store({ configName: 'keyManagement', defaults: { vault: null } });
export const createVault = async (password, mnemonic) => {
const keyRingController = new KeyringController({});
const controller = await keyRingController.createNewVaultAndRestore(password, mnemonic);
const vault = keyRingController.store.getState();
storeKeyData(JSON.stringify(vault));
return keyRingController;
}
export const restoreVault = async (password) => {
const keyStore = JSON.parse(getKeyData());
const keyRingController = new KeyringController({
initState: keyStore
});
const controller = await keyRingController.submitPassword(password);
return keyRingController;
}
export const getKeyData = () => store.get('vault');
export const storeKeyData = vault => {
store.set('vault', vault);
}
export const wipeVault = () => { store.set('vault', null); }

2
src/utils/parsers.js Normal file
View File

@ -0,0 +1,2 @@
const CONTACT_CODE_REGEXP = /^(0x)?[0-9a-f]{130}$/i;
export const isContactCode = str => CONTACT_CODE_REGEXP.test(str);

11
src/utils/store.js Normal file
View File

@ -0,0 +1,11 @@
export default class Store {
constructor(opts) {}
get(key) {
return localStorage.getItem(key);
}
set(key, val) {
localStorage.setItem(key, val);
}
}

25
src/utils/syntaxLookup.js Normal file
View File

@ -0,0 +1,25 @@
// all options available here: https://github.com/conorhastings/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_PRISM.MD
export default {
'bs': 'bash',
'bf': 'brainfuck',
'c': 'c',
'cp': 'cpp',
'cl': 'clojure',
'cs': 'css',
'dk': 'docker',
'ht': 'http',
'js': 'javascript',
'jn': 'json',
'jx': 'jsx',
'la': 'latex',
'ma': 'makefile',
'md': 'markdown',
'nm': 'nim',
'pb': 'protobuf',
'pu': 'puppet',
'py': 'python',
'sq': 'sql',
'ts': 'typescript',
'vi': 'vim',
'ym': 'yaml'
}

16
src/utils/windows.js Normal file
View File

@ -0,0 +1,16 @@
export const openBrowserWindow = url => {
window.open(url, '_blank', 'nodeIntegration=no');
}
export const addWindowEventListeners = (sendMessage) => {
window.addEventListener('message', function (msg) {
console.log('message', msg)
if (msg.source === window.parent) {
console.log(msg.data)
}
if (msg.data && msg.data.type && msg.data.type === 'whisperMsg') {
sendMessage(msg.data.msg)
}
})
}

7112
yarn.lock

File diff suppressed because it is too large Load Diff