Improve message rendering

This commit is contained in:
Ken-Håvard Lieng 2016-02-16 22:43:25 +01:00
parent ede8e19722
commit 072daa64f2
16 changed files with 303 additions and 260 deletions

File diff suppressed because one or more lines are too long

View File

@ -4,59 +4,59 @@
"description": "",
"main": "index.js",
"devDependencies": {
"babel-core": "^6.4.5",
"babel-core": "^6.5.2",
"babel-eslint": "^5.0.0-beta6",
"babel-loader": "^6.2.2",
"babel-plugin-react-transform": "^2.0.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-react-constant-elements": "^6.4.0",
"babel-plugin-transform-react-inline-elements": "^6.4.0",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
"babel-preset-stage-0": "^6.3.13",
"babel-plugin-transform-react-constant-elements": "^6.5.0",
"babel-plugin-transform-react-inline-elements": "^6.5.0",
"babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.5.0",
"babel-preset-stage-0": "^6.5.0",
"css-loader": "^0.23.1",
"eslint": "^1.10.3",
"eslint-config-airbnb": "^5.0.0",
"eslint-config-airbnb": "^5.0.1",
"eslint-loader": "^1.2.1",
"eslint-plugin-react": "^3.16.1",
"express": "^4.13.4",
"express-http-proxy": "^0.6.0",
"gulp": "^3.9.0",
"gulp": "^3.9.1",
"gulp-autoprefixer": "3.1.0",
"gulp-cached": "^1.1.0",
"gulp-concat": "^2.6.0",
"gulp-cssnano": "^2.1.0",
"gulp-cssnano": "^2.1.1",
"gulp-gzip": "1.2.0",
"gulp-htmlmin": "^1.3.0",
"gulp-util": "^3.0.7",
"ify-loader": "^1.0.3",
"react-transform-catch-errors": "^1.0.2",
"react-transform-hmr": "^1.0.2",
"redbox-react": "^1.2.2",
"redux-devtools": "^3.1.0",
"redux-devtools": "^3.1.1",
"redux-devtools-dock-monitor": "^1.0.1",
"redux-devtools-log-monitor": "^1.0.4",
"style-loader": "^0.13.0",
"webpack": "^1.12.13",
"webpack-dev-middleware": "^1.5.1",
"webpack-hot-middleware": "^2.6.4"
"webpack-hot-middleware": "^2.7.1"
},
"dependencies": {
"autolinker": "^0.22.0",
"autolinker": "^0.23.0",
"backo": "^1.1.0",
"base64-arraybuffer": "^0.1.5",
"eventemitter2": "^0.4.14",
"history": "^2.0.0",
"immutable": "^3.7.6",
"lodash": "^4.2.1",
"lodash": "^4.3.0",
"pure-render-decorator": "^0.2.0",
"react": "^0.14.7",
"react-dom": "^0.14.7",
"react-infinite": "0.8.0",
"react-redux": "^4.4.0",
"react-router": "^2.0.0-rc5",
"react-router": "^2.0.0",
"react-router-redux": "^3.0.0",
"react-virtualized": "^4.8.1",
"redux": "^3.3.0",
"react-virtualized": "^4.10.0",
"redux": "^3.3.1",
"redux-thunk": "^1.0.3",
"reselect": "^2.0.3"
}

View File

@ -396,19 +396,19 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
bottom: 50px;
right: 0;
z-index: 1;
overflow: hidden;
}
.chat-channel .messagebox {
right: 200px;
}
.messagebox-scrollable {
padding: 10px 15px;
overflow-y: auto !important;
.VirtualScroll {
overflow-x: hidden !important;
}
.message {
white-space: nowrap;
padding: 3px 15px;
}
.message-info {

View File

@ -43,5 +43,6 @@ export const TAB_HISTORY_POP = 'TAB_HISTORY_POP';
export const TOGGLE_MENU = 'TOGGLE_MENU';
export const TOGGLE_SEARCH = 'TOGGLE_SEARCH';
export const TOGGLE_USERLIST = 'TOGGLE_USERLIST';
export const UPDATE_MESSAGE_HEIGHT = 'UPDATE_MESSAGE_HEIGHT';
export const UPLOAD_CERT = 'UPLOAD_CERT';
export const WHOIS = 'WHOIS';

View File

@ -1,9 +1,42 @@
import * as actions from '../actions';
import { messageHeight } from '../util';
function initMessage(message, state) {
message.dest = message.to || message.from || message.server;
if (message.from && message.from.indexOf('.') !== -1) {
message.dest = message.server;
}
if (message.dest.charAt(0) === '#') {
message.channel = true;
}
// Combine multiple adjacent spaces into a single one
message.message = message.message.replace(/\s\s+/g, ' ');
const charWidth = state.environment.get('charWidth');
const wrapWidth = state.environment.get('wrapWidth');
message.height = messageHeight(message, wrapWidth, charWidth, 6 * charWidth);
return message;
}
export function updateMessageHeight() {
return (dispatch, getState) => dispatch({
type: actions.UPDATE_MESSAGE_HEIGHT,
wrapWidth: getState().environment.get('wrapWidth'),
charWidth: getState().environment.get('charWidth')
});
}
export function sendMessage(message, to, server) {
return (dispatch, getState) => dispatch({
return (dispatch, getState) => {
const state = getState();
dispatch(initMessage({
type: actions.SEND_MESSAGE,
from: getState().servers.getIn([server, 'nick']),
from: state.servers.getIn([server, 'nick']),
message,
to,
server,
@ -12,25 +45,31 @@ export function sendMessage(message, to, server) {
type: 'chat',
data: { message, to, server }
}
});
}, state));
};
}
export function addMessage(message) {
message.time = new Date();
return {
return (dispatch, getState) => dispatch({
type: actions.ADD_MESSAGE,
message
};
message: initMessage(message, getState())
});
}
export function addMessages(messages) {
const now = new Date();
messages.forEach(message => message.time = now);
return {
return (dispatch, getState) => {
const state = getState();
messages.forEach(message => initMessage(message, state).time = now);
dispatch({
type: actions.ADD_MESSAGES,
messages
});
};
}

View File

@ -1,12 +1,10 @@
import React, { Component } from 'react';
import Autolinker from 'autolinker';
import pure from 'pure-render-decorator';
import { timestamp } from '../util';
export default class MessageHeader extends Component {
shouldComponentUpdate(nextProps) {
return nextProps.message.lines[0] !== this.props.message.lines[0];
}
@pure
export default class Message extends Component {
handleSenderClick = () => {
const { message, openPrivateChat, select } = this.props;
@ -16,7 +14,7 @@ export default class MessageHeader extends Component {
render() {
const { message } = this.props;
const line = Autolinker.link(message.lines[0], { stripPrefix: false });
const content = Autolinker.link(message.message, { stripPrefix: false });
const classes = ['message'];
let sender = null;
@ -35,11 +33,16 @@ export default class MessageHeader extends Component {
);
}
const style = {
paddingLeft: `${window.messageIndent + 15}px`,
textIndent: `-${window.messageIndent}px`
};
return (
<p className={classes.join(' ')}>
<p className={classes.join(' ')} style={style}>
<span className="message-time">{timestamp(message.time)}</span>
{sender}
<span dangerouslySetInnerHTML={{ __html: ` ${line}` }}></span>
<span dangerouslySetInnerHTML={{ __html: ` ${content}` }}></span>
</p>
);
}

View File

@ -1,8 +1,7 @@
import React, { Component } from 'react';
import Infinite from 'react-infinite';
import { VirtualScroll } from 'react-virtualized';
import pure from 'pure-render-decorator';
import MessageHeader from './MessageHeader';
import MessageLine from './MessageLine';
import Message from './Message';
@pure
export default class MessageBox extends Component {
@ -15,16 +14,22 @@ export default class MessageBox extends Component {
window.addEventListener('resize', this.handleResize);
}
componentWillUpdate() {
const el = this.refs.list.refs.scrollable;
componentWillReceiveProps() {
const el = this.refs.list.refs.scrollingContainer;
this.autoScroll = el.scrollTop + el.offsetHeight === el.scrollHeight;
}
componentWillUpdate(nextProps) {
if (nextProps.messages !== this.props.messages) {
this.refs.list.recomputeRowHeights();
}
}
componentDidUpdate() {
setTimeout(this.updateWidth, 0);
this.updateWidth();
if (this.autoScroll) {
const el = this.refs.list.refs.scrollable;
const el = this.refs.list.refs.scrollingContainer;
el.scrollTop = el.scrollHeight;
}
}
@ -33,57 +38,71 @@ export default class MessageBox extends Component {
window.removeEventListener('resize', this.handleResize);
}
updateWidth = () => {
const { setWrapWidth } = this.props;
getRowHeight = index => {
const { messages } = this.props;
if (index === 0 || index === messages.size + 1) {
return 7;
}
return messages.get(index - 1).height;
};
updateWidth = resize => {
const { isChannel, setWrapWidth, updateMessageHeight } = this.props;
const { list } = this.refs;
if (list) {
const width = list.refs.scrollable.offsetWidth - 30;
let width = list.refs.scrollingContainer.clientWidth - 30;
if (isChannel) {
width += 200;
}
if (this.width !== width) {
this.width = width;
setWrapWidth(width);
if (resize) {
updateMessageHeight();
}
}
}
};
handleResize = () => {
this.updateWidth();
this.updateWidth(true);
this.setState({ height: window.innerHeight - 100 });
};
render() {
const { tab, messages, select, openPrivateChat } = this.props;
const dest = tab.channel || tab.user || tab.server;
const lines = [];
renderMessage = index => {
const { messages } = this.props;
messages.forEach((message, j) => {
const key = message.server + dest + j;
lines.push(
<MessageHeader
key={key}
if (index === 0 || index === messages.size + 1) {
return <span style={{ height: '7px' }}></span>;
}
const { select, openPrivateChat } = this.props;
const message = messages.get(index - 1);
return (
<Message
message={message}
select={select}
openPrivateChat={openPrivateChat}
/>
);
};
for (let i = 1; i < message.lines.length; i++) {
lines.push(
<MessageLine key={`${key}-${i}`} type={message.type} line={message.lines[i]} />
);
}
});
render() {
return (
<div className="messagebox">
<Infinite
<VirtualScroll
ref="list"
className="messagebox-scrollable"
containerHeight={this.state.height}
elementHeight={24}
displayBottomUpwards={false}
>
{lines}
</Infinite>
height={this.state.height}
rowsCount={this.props.messages.size + 2}
rowHeight={this.getRowHeight}
rowRenderer={this.renderMessage}
/>
</div>
);
}

View File

@ -1,26 +0,0 @@
import React, { Component } from 'react';
import Autolinker from 'autolinker';
import pure from 'pure-render-decorator';
@pure
export default class MessageLine extends Component {
render() {
const { line, type } = this.props;
const content = Autolinker.link(line, { stripPrefix: false });
const classes = ['message'];
if (type) {
classes.push(`message-${type}`);
}
const style = {
paddingLeft: `${window.messageIndent}px`
};
return (
<p className={classes.join(' ')} style={style}>
<span dangerouslySetInnerHTML={{ __html: content }}></span>
</p>
);
}
}

View File

@ -13,6 +13,12 @@ export default class UserList extends Component {
window.addEventListener('resize', this.handleResize);
}
componentWillUpdate(nextProps) {
if (nextProps.users.size === this.props.users.size) {
this.refs.list.forceUpdate();
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
@ -25,9 +31,7 @@ export default class UserList extends Component {
return 24;
};
handleResize = () => {
this.setState({ height: window.innerHeight - 100 });
};
handleResize = () => this.setState({ height: window.innerHeight - 100 });
renderUser = index => {
const { users } = this.props;
@ -61,6 +65,7 @@ export default class UserList extends Component {
return (
<div className={className} style={style}>
<VirtualScroll
ref="list"
height={this.state.height}
rowsCount={this.props.users.size + 2}
rowHeight={this.getRowHeight}

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { createSelector, createStructuredSelector } from 'reselect';
import { List, Map } from 'immutable';
import pure from 'pure-render-decorator';
import ChatTitle from '../components/ChatTitle';
@ -13,10 +13,10 @@ import { part } from '../actions/channel';
import { openPrivateChat, closePrivateChat } from '../actions/privateChat';
import { searchMessages, toggleSearch } from '../actions/search';
import { select, setSelectedChannel, setSelectedUser } from '../actions/tab';
import { runCommand, sendMessage } from '../actions/message';
import { runCommand, sendMessage, updateMessageHeight } from '../actions/message';
import { disconnect } from '../actions/server';
import { setWrapWidth, setCharWidth } from '../actions/environment';
import { stringWidth, wrapMessages } from '../util';
import { stringWidth } from '../util';
import { toggleUserList } from '../actions/ui';
import * as inputHistoryActions from '../actions/inputHistory';
@ -64,7 +64,8 @@ class Chat extends Component {
};
render() {
const { tab, channel, search, history, dispatch } = this.props;
const { title, tab, channel, search, history,
messages, users, showUserList, inputActions } = this.props;
let chatClass;
if (tab.channel) {
@ -77,21 +78,43 @@ class Chat extends Component {
return (
<div className={chatClass}>
<ChatTitle {...this.props } />
<ChatTitle
title={title}
tab={tab}
channel={channel}
toggleSearch={this.props.toggleSearch}
toggleUserList={this.props.toggleUserList}
disconnect={this.props.disconnect}
part={this.props.part}
closePrivateChat={this.props.closePrivateChat}
/>
<Search
search={search}
onSearch={this.handleSearch}
/>
<MessageBox {...this.props } />
<MessageBox
messages={messages}
isChannel={tab.channel !== null}
setWrapWidth={this.props.setWrapWidth}
updateMessageHeight={this.props.updateMessageHeight}
select={this.props.select}
openPrivateChat={this.props.openPrivateChat}
/>
<MessageInput
tab={tab}
channel={channel}
history={history}
runCommand={this.props.runCommand}
sendMessage={this.props.sendMessage}
history={history}
{...bindActionCreators(inputHistoryActions, dispatch)}
{...inputActions}
/>
<UserList
users={users}
tab={tab}
showUserList={showUserList}
select={this.props.select}
openPrivateChat={this.props.openPrivateChat}
/>
<UserList {...this.props} />
</div>
);
}
@ -99,6 +122,12 @@ class Chat extends Component {
const tabSelector = state => state.tab.selected;
const messageSelector = state => state.messages;
const serverSelector = state => state.servers;
const channelSelector = state => state.channels;
const searchSelector = state => state.search;
const showUserListSelector = state => state.ui.showUserList;
const historySelector = state =>
state.input.index === -1 ? null : state.input.history.get(state.input.index);
const selectedMessagesSelector = createSelector(
tabSelector,
@ -106,40 +135,33 @@ const selectedMessagesSelector = createSelector(
(tab, messages) => messages.getIn([tab.server, tab.channel || tab.user || tab.server], List())
);
const wrapWidthSelector = state => state.environment.get('wrapWidth');
const charWidthSelector = state => state.environment.get('charWidth');
const wrappedMessagesSelector = createSelector(
selectedMessagesSelector,
wrapWidthSelector,
charWidthSelector,
(messages, width, charWidth) => wrapMessages(messages, width, charWidth, 6 * charWidth)
const selectedChannelSelector = createSelector(
tabSelector,
channelSelector,
(tab, channels) => channels.getIn([tab.server, tab.channel], Map())
);
function mapStateToProps(state) {
const tab = state.tab.selected;
const channel = state.channels.getIn([tab.server, tab.channel], Map());
const usersSelector = createSelector(
selectedChannelSelector,
channel => channel.get('users', List())
);
let title;
if (tab.channel) {
title = tab.channel;
} else if (tab.user) {
title = tab.user;
} else {
title = state.servers.getIn([tab.server, 'name']);
}
const titleSelector = createSelector(
tabSelector,
serverSelector,
(tab, servers) => tab.channel || tab.user || servers.getIn([tab.server, 'name'])
);
return {
title,
search: state.search,
users: channel.get('users', List()),
history: state.input.index === -1 ? null : state.input.history.get(state.input.index),
messages: wrappedMessagesSelector(state),
showUserList: state.ui.showUserList,
channel,
tab
};
}
const mapStateToProps = createStructuredSelector({
title: titleSelector,
tab: tabSelector,
channel: selectedChannelSelector,
messages: selectedMessagesSelector,
users: usersSelector,
showUserList: showUserListSelector,
search: searchSelector,
history: historySelector
});
function mapDispatchToProps(dispatch) {
return {
@ -155,8 +177,10 @@ function mapDispatchToProps(dispatch) {
disconnect,
openPrivateChat,
closePrivateChat,
setWrapWidth
}, dispatch)
setWrapWidth,
updateMessageHeight
}, dispatch),
inputActions: bindActionCreators(inputHistoryActions, dispatch)
};
}

View File

@ -1,5 +1,6 @@
import { List, Map, Record } from 'immutable';
import createReducer from '../util/createReducer';
import { messageHeight } from '../util';
import * as actions from '../actions';
const Message = Record({
@ -10,15 +11,11 @@ const Message = Record({
message: '',
time: null,
type: null,
lines: []
height: 0,
channel: false
});
function addMessage(state, message) {
let dest = message.to || message.from || message.server;
if (message.from && message.from.indexOf('.') !== -1) {
dest = message.server;
}
if (message.message.indexOf('\x01ACTION') === 0) {
const from = message.from;
message.from = null;
@ -26,7 +23,8 @@ function addMessage(state, message) {
message.message = from + message.message.slice(7);
}
return state.updateIn([message.server, dest], List(), list => list.push(new Message(message)));
return state.updateIn([message.server, message.dest], List(),
list => list.push(new Message(message)));
}
export default createReducer(Map(), {
@ -45,15 +43,7 @@ export default createReducer(Map(), {
)
);
},
/*
[actions.SOCKET_MESSAGE](state, action) {
return addMessage(state, action);
},
[actions.SOCKET_PM](state, action) {
return addMessage(state, action);
},
*/
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
},
@ -64,5 +54,17 @@ export default createReducer(Map(), {
s.deleteIn([action.server, channel])
)
);
},
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
return state.withMutations(s =>
s.forEach((server, serverKey) =>
server.forEach((target, targetKey) =>
target.forEach((message, index) => s.setIn([serverKey, targetKey, index, 'height'],
messageHeight(message, action.wrapWidth, action.charWidth, 6 * action.charWidth))
)
)
)
);
}
});

View File

@ -1,6 +1,6 @@
import padStart from 'lodash/padStart';
export wrapMessages from './wrapMessages';
export messageHeight from './messageHeight';
export function normalizeChannel(channel) {
if (channel.indexOf('#') !== 0) {

View File

@ -0,0 +1,39 @@
const lineHeight = 24;
export default function messageHeight(message, width, charWidth, indent = 0) {
let pad = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
let height = lineHeight + 4;
if (message.channel) {
width -= 200;
}
if (pad + message.message.length * charWidth < width) {
return height;
}
let prevBreak = 0;
let prevPos = 0;
for (let i = 0, len = message.message.length; i < len; i++) {
const c = message.message.charAt(i);
if (c === ' ' || c === '-') {
const end = c === ' ' ? i : i + 1;
if (pad + (end - prevBreak) * charWidth >= width) {
prevBreak = prevPos;
pad = indent;
height += lineHeight;
}
prevPos = i + 1;
} else if (i === len - 1) {
if (pad + (len - prevBreak) * charWidth >= width) {
height += lineHeight;
}
}
}
return height;
}

View File

@ -1,65 +0,0 @@
export default function wrapMessages(messages, width, charWidth, indent = 0) {
return messages.withMutations(m => {
for (let j = 0, llen = messages.size; j < llen; j++) {
const message = messages.get(j);
let lineWidth = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
if (lineWidth + message.message.length * charWidth < width) {
m.setIn([j, 'lines'], [message.message]);
continue;
}
const words = message.message.split(' ');
const wrapped = [];
let line = '';
let wordCount = 0;
let hasWrapped = false;
// Add empty line if first word after timestamp + sender wraps
if (words.length > 0 && message.from && lineWidth + words[0].length * charWidth >= width) {
wrapped.push(line);
lineWidth = 0;
}
for (let i = 0, wlen = words.length; i < wlen; i++) {
const word = words[i];
if (hasWrapped) {
hasWrapped = false;
lineWidth += indent;
}
lineWidth += word.length * charWidth;
wordCount++;
if (lineWidth >= width) {
if (wordCount !== 1) {
wrapped.push(line);
if (i !== wlen - 1) {
line = `${word} `;
lineWidth = (word.length + 1) * charWidth;
wordCount = 1;
} else {
wrapped.push(word);
}
} else {
wrapped.push(word);
lineWidth = 0;
wordCount = 0;
}
hasWrapped = true;
} else if (i !== wlen - 1) {
line += `${word} `;
lineWidth += charWidth;
} else {
line += word;
wrapped.push(line);
}
}
m.setIn([j, 'lines'], wrapped);
}
});
}

View File

@ -18,7 +18,8 @@ module.exports = {
],
loaders: [
{ test: /\.js$/, loader: 'babel', exclude: /node_modules/ },
{ test: /\.css$/, loader: 'style!css' }
{ test: /\.css$/, loader: 'style!css' },
{ test: /node_modules/, loader: 'ify' }
]
},
plugins: [

View File

@ -16,7 +16,8 @@ module.exports = {
],
loaders: [
{ test: /\.js$/, loader: 'babel', exclude: /node_modules/ },
{ test: /\.css$/, loader: 'style!css' }
{ test: /\.css$/, loader: 'style!css' },
{ test: /node_modules/, loader: 'ify' }
]
},
plugins: [