mirror of https://github.com/status-im/chat.git
initial add status-js + Murmur + ui components
This commit is contained in:
parent
5d1164e87f
commit
2d2e23743a
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
|
@ -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",
|
||||||
|
|
20
src/App.js
20
src/App.js
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
||||||
|
.sidebar {
|
||||||
|
background-color: #4d394b;
|
||||||
|
}
|
|
@ -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;
|
|
@ -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);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const ChatContext = React.createContext('chat');
|
|
@ -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;
|
|
@ -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
|
|
@ -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,0 +1,8 @@
|
||||||
|
export class User {
|
||||||
|
constructor(pubkey, username) {
|
||||||
|
this.pubkey = pubkey;
|
||||||
|
this.username = username;
|
||||||
|
this.online = false;
|
||||||
|
this.lastSeen = 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -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); }
|
|
@ -0,0 +1,2 @@
|
||||||
|
const CONTACT_CODE_REGEXP = /^(0x)?[0-9a-f]{130}$/i;
|
||||||
|
export const isContactCode = str => CONTACT_CODE_REGEXP.test(str);
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default class Store {
|
||||||
|
constructor(opts) {}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, val) {
|
||||||
|
localStorage.setItem(key, val);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in New Issue