diff --git a/client/package.json b/client/package.json index f8b9951..27f5345 100644 --- a/client/package.json +++ b/client/package.json @@ -14,7 +14,7 @@ "file-loader": "^0.8.5", "halogen": "^0.2.0", "highlight.js": "^9.4.0", - "ipfs": "^0.11.0", + "ipfs": "^0.13.0", "lodash": "^4.12.0", "logplease": "^1.2.7", "normalize.css": "~4.1.1", diff --git a/client/src/components/Channel.js b/client/src/components/Channel.js index e3e2880..a5f663c 100644 --- a/client/src/components/Channel.js +++ b/client/src/components/Channel.js @@ -2,16 +2,15 @@ import _ from 'lodash'; import React from 'react'; -import TransitionGroup from "react-addons-css-transition-group"; import Message from 'components/Message'; -import SendMessage from 'components/SendMessage'; +import ChannelControls from 'components/ChannelControls'; +import NewMessageNotification from 'components/NewMessageNotification'; import Dropzone from 'react-dropzone'; import MessageStore from 'stores/MessageStore'; import ChannelStore from 'stores/ChannelStore'; import LoadingStateStore from 'stores/LoadingStateStore'; import UIActions from 'actions/UIActions'; import ChannelActions from 'actions/ChannelActions'; -import Halogen from 'halogen'; import 'styles/Channel.scss'; import Logger from 'logplease'; const logger = Logger.create('Channel', { color: Logger.Colors.Cyan }); @@ -31,7 +30,6 @@ class Channel extends React.Component { error: null, dragEnter: false, username: props.user ? props.user.username : '', - displayNewMessagesIcon: false, unreadMessages: 0, appSettings: props.appSettings, theme: props.theme @@ -48,7 +46,7 @@ class Channel extends React.Component { if(nextProps.channel !== this.state.channelName) { this.setState({ channelChanged: true, - displayNewMessagesIcon: false, + unreadMessages: 0, loading: true, reachedChannelStart: false, messages: [] @@ -158,7 +156,6 @@ class Channel extends React.Component { && this.state.messages.length > 0 && _.last(messages).payload.meta.ts > _.last(this.state.messages).payload.meta.ts && this.node.scrollHeight > 0) { this.setState({ - displayNewMessagesIcon: true, unreadMessages: this.state.unreadMessages + 1 }); } @@ -171,9 +168,9 @@ class Channel extends React.Component { ChannelActions.sendMessage(this.state.channelName, text); } - sendFile(filePath: string, buffer) { + sendFile(filePath: string, buffer, meta) { if(filePath !== '' || buffer !== null) - ChannelActions.addFile(this.state.channelName, filePath, buffer); + ChannelActions.addFile(this.state.channelName, filePath, buffer, meta); } loadOlderMessages() { @@ -222,15 +219,15 @@ class Channel extends React.Component { onDrop(files) { this.setState({ dragEnter: false }); - console.log('Dropped files: ', files); files.forEach((file) => { if(file.path) { this.sendFile(file.path); } else { const reader = new FileReader(); reader.onload = (event) => { - console.log(file, event); - this.sendFile(file.name, event.target.result) + this.sendFile(file.name, event.target.result, { + mimeType: file.type, + }); }; reader.readAsArrayBuffer(file); // console.error("File upload not yet implemented in browser. Try the electron app."); @@ -261,7 +258,9 @@ class Channel extends React.Component { // If we scrolled to the bottom, hide the "new messages" label this.node = this.refs.MessagesView; if(this.node.scrollHeight - this.node.scrollTop - 10 <= this.node.clientHeight) { - this.setState({ displayNewMessagesIcon: false, unreadMessages: 0 }); + this.setState({ + unreadMessages: 0 + }); } } @@ -270,89 +269,70 @@ class Channel extends React.Component { this.node.scrollTop = this.node.scrollHeight + this.node.clientHeight; } - render() { - const theme = this.state.theme; - const channelMode = (
{this.state.channelMode}
); - - const controlsBar = ( - -
- - -
- -
- - ); - - const messages = this.state.messages.map((e) => { - return ; - }); - - // let channelStateText = this.state.loading && this.state.loadingText ? this.state.loadingText : `Loading messages...`; - let channelStateText = this.state.loadingText ? this.state.loadingText : `???`; - if(this.state.reachedChannelStart && !this.state.loading) - channelStateText = `Beginning of #${this.state.channelName}`; - - messages.unshift(
{channelStateText}
); - - const fileDrop = this.state.dragEnter ? ( - ( + -
Add files to #{this.state.channelName}
-
- ) : ""; - - const showNewMessageNotification = this.state.displayNewMessagesIcon ? ( -
- There are {this.state.unreadMessages} new messages + highlightWords={username} + colorifyUsername={colorifyUsernames} + useEmojis={useEmojis} + style={{ + fontFamily: useMonospaceFont ? monospaceFont : font, + fontSize: useMonospaceFont ? '0.9em' : '1.0em', + fontWeight: useMonospaceFont ? '100' : '300', + padding: spacing, + }} + /> + )); + elements.unshift( +
+ {reachedChannelStart && !loading ? `Beginning of #${channelName}` : loadingText || '???'}
- ) : (); + ); + return elements; + } - const loadingIcon = this.state.loading ? ( -
- -
- ) : ""; + renderFileDrop() { + const { theme, dragEnter, channelName } = this.state; + if (dragEnter) { + return ( + +
Add files to #{channelName}
+
+ ); + } + return null; + } + render() { + const { unreadMessages, loading, channelMode, appSettings, theme } = this.state; return (
-
- {messages} +
+ {this.renderMessages()}
- {showNewMessageNotification} - {controlsBar} - {fileDrop} - {loadingIcon} - {channelMode} + + + {this.renderFileDrop()}
); } diff --git a/client/src/components/ChannelControls.js b/client/src/components/ChannelControls.js new file mode 100644 index 0000000..3c21b39 --- /dev/null +++ b/client/src/components/ChannelControls.js @@ -0,0 +1,46 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import TransitionGroup from "react-addons-css-transition-group"; +import Dropzone from 'react-dropzone'; +import Halogen from 'halogen'; +import Spinner from 'components/Spinner'; +import SendMessage from 'components/SendMessage'; + +class ChannelControls extends React.Component { + + static propTypes = { + onSendMessage: PropTypes.func, + onSendFiles: PropTypes.func, + isLoading: PropTypes.bool, + channelMode: PropTypes.string, + appSettings: PropTypes.object, + theme: PropTypes.object, + }; + + render() { + const { onSendMessage, onSendFiles, isLoading, channelMode, appSettings, theme } = this.props; + return ( + +
+ + + +
+ +
{channelMode}
+
+ + ); + } + +} + +export default ChannelControls; diff --git a/client/src/components/ChannelsPanel.js b/client/src/components/ChannelsPanel.js index 3b76572..3db6b2e 100644 --- a/client/src/components/ChannelsPanel.js +++ b/client/src/components/ChannelsPanel.js @@ -8,7 +8,7 @@ import ChannelStore from 'stores/ChannelStore'; import AppStateStore from 'stores/AppStateStore'; import NetworkActions from 'actions/NetworkActions'; import BackgroundAnimation from 'components/BackgroundAnimation'; //eslint-disable-line -import Halogen from 'halogen'; //eslint-disable-line +import Spinner from 'components/Spinner'; import 'styles/ChannelsPanel.scss'; import 'styles/RecentChannels.scss'; @@ -87,48 +87,34 @@ class ChannelsPanel extends React.Component { } render() { - var headerStyle = this.state.currentChannel ? "header" : "header no-close"; - var color = 'rgba(140, 80, 220, 1)'; - var loadingIcon = this.state.loading ? ( -
- -
- ) : ""; - - var channelsHeaderStyle = this.state.openChannels.length > 0 ? "panelHeader" : "hidden"; - var openChannels = this.state.openChannels.length > 0 ? this.state.openChannels.map((f) => this._renderChannel(f)) : []; - var channelJoinInputStyle = !this.state.loading ? "joinChannelInput" : "joinChannelInput invisible"; + const headerClass = this.state.currentChannel ? "header" : "header no-close"; + const channelsHeaderClass = this.state.openChannels.length > 0 ? "panelHeader" : "hidden"; + const channelJoinInputClass = !this.state.loading ? "joinChannelInput" : "joinChannelInput invisible"; + + const openChannels = this.state.openChannels.map((channel) => this._renderChannel(channel)); + + const transitionProps = { + component: 'div', + transitionAppear: true, + transitionAppearTimeout: 5000, + transitionEnterTimeout: 5000, + transitionLeaveTimeout: 5000, + }; return (
- +
- - - -
+ + + + +
Orbit
- + +
{this.state.networkName}
@@ -136,16 +122,9 @@ class ChannelsPanel extends React.Component {
{this.state.username}
- {loadingIcon} + - + +
Channels
-
Channels
- - +
{openChannels}
- - - + + +
-
+
- - +
); } diff --git a/client/src/components/File.js b/client/src/components/File.js index ff8ad6f..1cd2809 100644 --- a/client/src/components/File.js +++ b/client/src/components/File.js @@ -2,169 +2,137 @@ import React from 'react'; import TransitionGroup from "react-addons-css-transition-group"; -import { getHumanReadableBytes } from '../utils/utils.js'; -import apiurl from 'utils/apiurl'; -import 'styles/File.scss'; -import Highlight from 'components/plugins/highlight'; +import Clipboard from 'clipboard'; import highlight from 'highlight.js' +import Highlight from 'components/plugins/highlight'; +import { getHumanReadableBytes } from '../utils/utils.js'; import ChannelActions from 'actions/ChannelActions'; -import Clipboard from 'clipboard'; - import MessageStore from 'stores/MessageStore'; - +import 'styles/File.scss'; import Logger from 'logplease'; const logger = Logger.create('Clipboard', { color: Logger.Colors.Magenta }); -var getFileUrl = apiurl.getApiUrl() + "/api/cat/"; +function readUTF8String(bytes) { + let i = 0; + let string = ''; + + // Remove UTF-8 BOM header, if present + const UTF8_BOM_HEADER = '\xef\xbb\xbf'; + if (bytes.slice(0, 3) == UTF8_BOM_HEADER) { + i = 3; + } + + // Convert UTF-8 encoded codepoints to JS string + // See https://en.wikipedia.org/wiki/UTF-8#Description + for (; i < bytes.byteLength; i++) { + const byte1 = bytes[i]; + if (byte1 < 0x80) { + // U+0000 .. U+007F + string += String.fromCharCode(byte1); + } else if (byte1 >= 0xc2 && byte1 < 0xe0) { + // U+0080 .. U+07FF + const byte2 = bytes[++i]; + string += String.fromCharCode(((byte1 & 0x1f) << 6) + (byte2 & 0x3f)); + } else if (byte1 >= 0xe0 && byte1 < 0xf0) { + // U+0800 .. U+FFFF + const byte2 = bytes[++i]; + const byte3 = bytes[++i]; + string += String.fromCharCode(((byte1 & 0xff) << 12) + ((byte2 & 0x3f) << 6) + (byte3 & 0x3f)); + } else if (byte1 >= 0xf0 && byte1 < 0xf5) { + // U+10000 .. U+1FFFFF + const byte2 = bytes[++i]; + const byte3 = bytes[++i]; + const byte4 = bytes[++i]; + const codepoint = (((byte1 & 0x07) << 18) + ((byte2 & 0x3f) << 12) + ((byte3 & 0x3f) << 6) + (byte4 & 0x3f)) - 0x10000; + string += String.fromCharCode((codepoint >> 10) + 0xd800, (codepoint & 0x3ff) + 0xdc00); + } + } + + return string; +} class File extends React.Component { + constructor(props) { super(props); + this.ext = /(?:\.([^.]+))?$/.exec(props.name)[1]; this.state = { - name: props.name, - file: props.hash, - size: props.size, showPreview: false, previewContent: "Loading...", }; - var self = this; - this.clipboard = new Clipboard('.clipboard-' + this.state.file, { + this.clipboard = new Clipboard('.clipboard-' + props.hash, { text: function(trigger) { - logger.info(self.state.file + " copied to clipboard!"); - return self.state.file; + logger.info(props.hash + " copied to clipboard!"); + return props.hash; } }); } - handleClick(name) { - const isCode = this._isTextFile(this.state.name); - - if(!this.state.showPreview && isCode) { - ChannelActions.loadFile(this.state.file, (file) => { - this.setState({ previewContent: {file} }); - }); - } else { - this.setState({ previewContent: "Loading..." }); - } + get isVideo() { + return this.ext === 'mp4' || this.ext === 'webm' || this.ext === 'ogv'; + } - function toArrayBuffer(buffer) { - var ab = new ArrayBuffer(buffer.length); - var view = new Uint8Array(ab); - for (var i = 0; i < buffer.length; ++i) { - view[i] = buffer[i]; - } - return ab; - } + get isAudio() { + return this.ext === 'mp3' || this.ext === 'ogg'; + } - if(!this.state.showPreview) { - setTimeout(() => { - var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'; - let source = new MediaSource(); - this.refs.video_tag.src = window.URL.createObjectURL(source); - - source.addEventListener('sourceopen', (e) => { - let sourceBuffer = source.addSourceBuffer(mimeCodec); - MessageStore.orbit.ipfs.files.cat(this.state.file, (err, res) => { - let buf = []; - sourceBuffer.addEventListener('updateend', () => { - if(buf.length > 0) - sourceBuffer.appendBuffer(buf.shift()) - }); - res - .on('error', (err) => console.error(err)) - .on('data', (data) => { - if(!sourceBuffer.updating) - sourceBuffer.appendBuffer(toArrayBuffer(data)); - else - buf.push(toArrayBuffer(data)); - }) - .on('end', () => { - console.log("END!!!") - setTimeout(() => { - source.endOfStream() - this.refs.video_tag.play(); - }, 100); - }) - }); - }, false); - }, 500); - } + get isImage() { + return this.ext === 'png' || this.ext === 'jpg' || this.ext === 'gif'; + } - this.setState({ showPreview: !this.state.showPreview }); + get isHighlightable() { + return highlight.getLanguage(this.ext); } - _isTextFile(name) { - return highlight.getLanguage(this.state.name.split('.').pop()); + handleClick(name) { + this.setState({ + showPreview: !this.state.showPreview, + previewContent: 'Loading...', + }, () => { + if (this.state.showPreview) { + ChannelActions.loadFile(this.props.hash, (blob) => { + let previewContent = 'Unable to display file.'; + if (blob) { + const url = window.URL.createObjectURL(blob); + if (this.isAudio) { + previewContent =
+ ); + return ( +
+ - {this.state.name} - Open - Download - Hash - {size} - {code} - ) - } else if(isPicture) { - content = - ( - {this.state.name} - Open - Download - Hash - {size} - {picture} - ) - } else { - content = - ( - {this.state.name} - Open - Download - Hash - {size} - ) - } - - return ( -
- {content} + className="content"> + {this.props.name} + {/* + Open + Download + */} + Hash + {size} + {this.state.showPreview && preview} +
); } diff --git a/client/src/components/Message.js b/client/src/components/Message.js index c565375..6eeac93 100644 --- a/client/src/components/Message.js +++ b/client/src/components/Message.js @@ -1,7 +1,6 @@ 'use strict'; import React from "react"; -import TransitionGroup from "react-addons-css-transition-group"; import MentionHighlighter from 'components/plugins/mention-highlighter'; import User from "components/User"; import File from "components/File"; @@ -9,67 +8,81 @@ import TextMessage from "components/TextMessage"; import Directory from "components/Directory"; import ChannelActions from 'actions/ChannelActions'; import NotificationActions from 'actions/NotificationActions'; +import { getFormattedTime } from '../utils/utils.js'; import "styles/Message.scss"; class Message extends React.Component { + constructor(props) { super(props); this.state = { post: null, hasHighlights: false, isCommand: false, + formattedTime: getFormattedTime(props.message.meta.ts), }; } componentDidMount() { ChannelActions.loadPost(this.props.message.value, (err, post) => { - if(post && post.content) { - if(post.content.startsWith('/me')) - this.setState({ isCommand: true }); - - post.content.split(" ").forEach((word) => { + const state = { + post: post + }; + if (post && post.content) { + if (post.content.startsWith('/me')) { + state.isCommand = true; + } + post.content.split(' ').forEach((word) => { const highlight = MentionHighlighter.highlight(word, this.props.highlightWords); if(typeof highlight[0] !== 'string' && this.props.highlightWords !== post.meta.from) { - this.setState({ hasHighlights: true }); - NotificationActions.mention(this.state.channelName, post.content); + state.hasHighlights = true; + NotificationActions.mention(this.state.channelName, post.content); // TODO: where does channelName come from? } }); } - this.setState({ post: post }); + this.setState(state); }); } - onDragEnter(event) { - this.props.onDragEnter(event); - } - - render() { - const safeTime = (time) => ("0" + time).slice(-2); - const date = new Date(this.props.message.meta.ts); - const ts = safeTime(date.getHours()) + ":" + safeTime(date.getMinutes()) + ":" + safeTime(date.getSeconds()); - - const className = this.state.hasHighlights ? "Message highlighted" : "Message"; - const contentClass = this.state.isCommand ? "Content command" : "Content"; - - const post = this.state.post; + renderContent() { + const { highlightWords, useEmojis } = this.props; + const { isCommand, post } = this.state; + const contentClass = isCommand ? "Content command" : "Content"; let content = (
...
); - - if(post && post.meta.type === "text") { - content = ; - } else if(post && post.meta.type === "file") { - content = ; - } else if(post && post.meta.type === "directory") { - content = ; + if (post) { + switch (post.meta.type) { + case 'text': + content = ( + + ); + break; + case 'file': + content = ; + break; + case 'directory': + content = ; + break; + } } + return
{content}
; + } + render() { + const { message, colorifyUsername, style, onDragEnter } = this.props; + const { post, isCommand, hasHighlights, formattedTime } = this.state; + const className = hasHighlights ? "Message highlighted" : "Message"; return ( -
- {ts} - -
{content}
+
+ {formattedTime} + + {this.renderContent()}
); } diff --git a/client/src/components/NewMessageNotification.js b/client/src/components/NewMessageNotification.js new file mode 100644 index 0000000..f0034e1 --- /dev/null +++ b/client/src/components/NewMessageNotification.js @@ -0,0 +1,34 @@ +'use strict'; + +import React, { PropTypes } from 'react'; + +class NewMessageNotification extends React.Component { + + static propTypes = { + onClick: PropTypes.func, + unreadMessages: PropTypes.number, + }; + + render() { + const { onClick, unreadMessages } = this.props; + if (unreadMessages > 0) { + if (unreadMessages === 1) { + return ( +
+ There is 1 new message +
+ ); + } else { + return ( +
+ There are {unreadMessages} new messages +
+ ); + } + } + return null; + } + +} + +export default NewMessageNotification; diff --git a/client/src/components/Spinner.js b/client/src/components/Spinner.js new file mode 100644 index 0000000..0353c15 --- /dev/null +++ b/client/src/components/Spinner.js @@ -0,0 +1,30 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import Halogen from 'halogen'; + +class Spinner extends React.Component { + + static propTypes = { + className: PropTypes.string, + isLoading: PropTypes.bool, + color: PropTypes.string, + size: PropTypes.string, + }; + + static defaultProps = { + className: 'spinner', + }; + + render() { + const { className, isLoading, color, size } = this.props; + return ( +
+ +
+ ); + } + +} + +export default Spinner; diff --git a/client/src/stores/MessageStore.js b/client/src/stores/MessageStore.js index 7ba2959..53f4b6e 100644 --- a/client/src/stores/MessageStore.js +++ b/client/src/stores/MessageStore.js @@ -300,11 +300,11 @@ const MessageStore = Reflux.createStore({ UIActions.stopLoading(channel, "send"); }); }, - onAddFile: function(channel: string, filePath: string, buffer) { - logger.debug("--> add file: " + filePath + buffer !== null); - this.orbit.addFile(channel, filePath, buffer); - - if(this.socket) { + onAddFile: function(channel: string, filePath: string, buffer, meta) { + logger.debug(`--> add file: ${channel} ${filePath} ${buffer !== null}`); + if(!this.socket) { + this.orbit.addFile(channel, filePath, buffer, meta); + } else { UIActions.startLoading(channel, "file"); this.socket.emit('file.add', channel, filePath, (err) => { if(err) { @@ -340,8 +340,10 @@ const MessageStore = Reflux.createStore({ } }, onLoadFile: function(hash, cb) { - if(!this.socket) + if(!this.socket) { + this.orbit.getFile(hash).then(cb).catch((err) => cb(null)); return; + } if(hash) { this.socket.emit('file.get', hash, (result) => { diff --git a/client/src/styles/Channel.scss b/client/src/styles/Channel.scss index a72b138..e5dedce 100644 --- a/client/src/styles/Channel.scss +++ b/client/src/styles/Channel.scss @@ -18,7 +18,7 @@ $controls-height: 2.8em; width: 100%; } - .loadingIcon { + .spinner { position: absolute; bottom: 0.75em; left: 0.75em; diff --git a/client/src/styles/ChannelsPanel.scss b/client/src/styles/ChannelsPanel.scss index 919abbc..ea88d36 100644 --- a/client/src/styles/ChannelsPanel.scss +++ b/client/src/styles/ChannelsPanel.scss @@ -115,7 +115,7 @@ $channel-name-min-height: 1.5em; margin-bottom: 1.0em; } - .loadingIcon { + .spinner { position: absolute; box-sizing: border-box; display: flex; @@ -283,4 +283,4 @@ $channel-name-min-height: 1.5em; animation-fill-mode: both; -webkit-animation-timing-function: ease-in; animation-timing-function: ease-in; -} \ No newline at end of file +} diff --git a/client/src/utils/utils.js b/client/src/utils/utils.js index 72ebf7c..f48283c 100644 --- a/client/src/utils/utils.js +++ b/client/src/utils/utils.js @@ -4,3 +4,9 @@ export function getHumanReadableBytes(size) { var i = Math.floor( Math.log(size) / Math.log(1024) ); return ( size / Math.pow(1024, i) ).toFixed(i > 2 ? 2 : 0) * 1 + ' ' + ['Bytes', 'kB', 'MB', 'GB', 'TB'][i]; } + +export function getFormattedTime(timestamp) { + const safeTime = (time) => ("0" + time).slice(-2); + const date = new Date(timestamp); + return safeTime(date.getHours()) + ":" + safeTime(date.getMinutes()) + ":" + safeTime(date.getSeconds()); +} diff --git a/package.json b/package.json index 9b13c5b..991e844 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "cors": "^2.7.1", "du": "^0.1.0", "express": "^4.13.4", - "ipfs-post": "https://github.com/haadcode/ipfs-post", + "ipfs-post": "^0.0.4", "ipfsd-ctl": "^0.14.0", "lodash": "^4.8.2", "logplease": "^1.2.7", @@ -26,7 +26,7 @@ "grunt-contrib-clean": "^1.0.0", "grunt-contrib-copy": "^1.0.0", "grunt-electron": "^2.0.1", - "ipfs": "^0.11.0", + "ipfs": "^0.13.0", "load-grunt-tasks": "^3.3.0", "mocha": "^2.4.5", "orbit-db-eventstore": "0.0.11", diff --git a/src/Orbit.js b/src/Orbit.js index f59bd95..b753f9b 100644 --- a/src/Orbit.js +++ b/src/Orbit.js @@ -174,7 +174,7 @@ class Orbit { }); } - addFile(channel, filePath, buffer) { + addFile(channel, filePath, buffer, meta) { console.log("!!!!!!!!!!!!!", typeof filePath === 'string') console.log(channel, filePath); @@ -218,7 +218,8 @@ class Orbit { name: filePath.split("/").pop(), hash: hash, size: size, - from: this.orbitdb.user.id + from: this.orbitdb.user.id, + meta: meta, }; const type = isDirectory ? Post.Types.Directory : Post.Types.File; @@ -239,12 +240,25 @@ class Orbit { } getFile(hash, callback) { - // request('http://localhost:8080/ipfs/' + hash, function (error, response, body) { - // if(!error && response.statusCode === 200) { - if(callback) callback(null); - // if(callback) callback(body); - // } - // }) + if (callback) { + callback(null); + return; + } + return this.ipfs.files.cat(hash).then(res => { + return new Promise((resolve, reject) => { + const buffers = []; + res + .on('error', err => { + reject(err); + }) + .on('data', data => { + buffers.push(data); + }) + .on('end', () => { + resolve(new Blob(buffers)); + }); + }); + }); } _handleError(e) {