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",
|
||||
"private": true,
|
||||
"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-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": {
|
||||
"start": "react-scripts start",
|
||||
|
|
20
src/App.js
20
src/App.js
|
@ -1,27 +1,11 @@
|
|||
import React, { Component } from 'react';
|
||||
import Home from './components/Home'
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
|
||||
class App extends Component {
|
||||
render() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <Home />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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