Support changing the nick by clicking it in MessageInput

This commit is contained in:
Ken-Håvard Lieng 2017-06-21 07:23:07 +02:00
parent 4a74463ae8
commit f174d98107
16 changed files with 335 additions and 134 deletions

File diff suppressed because one or more lines are too long

View File

@ -323,8 +323,10 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
.chat-title { .chat-title {
margin-left: 15px; margin-left: 15px;
font-size: 24px !important; font: 24px Montserrat, sans-serif;
white-space: nowrap; white-space: nowrap;
background: none;
line-height: 50px;
} }
.chat-topic-wrap { .chat-topic-wrap {
@ -502,13 +504,14 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
} }
.message-input-nick { .message-input-nick {
display: block;
margin: 10px; margin: 10px;
line-height: 30px; line-height: 30px;
height: 30px; height: 30px;
padding: 0 10px; padding: 0 10px;
background: #6BB758; background: #6BB758 !important;
color: #FFF; color: #FFF;
font-family: Montserrat, sans-serif; font-family: Montserrat, sans-serif !important;
margin-right: 0; margin-right: 0;
} }

View File

@ -1,4 +1,5 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import Editable from './ui/Editable';
export default class MessageInput extends PureComponent { export default class MessageInput extends PureComponent {
state = { state = {
@ -35,10 +36,17 @@ export default class MessageInput extends PureComponent {
}; };
render() { render() {
const { nick, currentHistoryEntry } = this.props; const { nick, currentHistoryEntry, onNickChange, onNickEditDone } = this.props;
return ( return (
<div className="message-input-wrap"> <div className="message-input-wrap">
<span className="message-input-nick">{nick}</span> <Editable
className="message-input-nick"
value={nick}
onBlur={onNickEditDone}
onChange={onNickChange}
>
<span className="message-input-nick">{nick}</span>
</Editable>
<input <input
className="message-input" className="message-input"
type="text" type="text"

View File

@ -36,6 +36,16 @@ export default class Chat extends Component {
setServerName(title, tab.server); setServerName(title, tab.server);
}; };
handleNickChange = nick => {
const { setNick, tab } = this.props;
setNick(nick, tab.server, true);
};
handleNickEditDone = nick => {
const { setNick, tab } = this.props;
setNick(nick, tab.server);
};
render() { render() {
const { const {
channel, channel,
@ -96,6 +106,8 @@ export default class Chat extends Component {
tab={tab} tab={tab}
onCommand={runCommand} onCommand={runCommand}
onMessage={sendMessage} onMessage={sendMessage}
onNickChange={this.handleNickChange}
onNickEditDone={this.handleNickEditDone}
{...inputActions} {...inputActions}
/> />
<UserList <UserList

View File

@ -1,12 +1,41 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { stringWidth } from '../../util';
const style = {
background: 'none',
font: 'inherit'
};
export default class Editable extends PureComponent { export default class Editable extends PureComponent {
state = { editing: false }; static defaultProps = {
editable: true
};
state = {
editing: false
};
componentWillReceiveProps(nextProps) {
if (this.state.editing && nextProps.value !== this.props.value) {
this.setState({
width: this.getInputWidth(nextProps.value)
});
}
}
componentDidUpdate(prevProps, prevState) {
if (!prevState.editing && this.state.editing) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
width: this.getInputWidth(this.props.value)
});
}
}
getInputWidth(value) {
if (this.input) {
const style = window.getComputedStyle(this.input);
const padding = parseInt(style.paddingLeft, 10) + parseInt(style.paddingRight, 10);
// Make sure the width is atleast 1px so the caret always shows
const width = stringWidth(value, style.font) || 1;
return padding + width;
}
}
startEditing = () => { startEditing = () => {
if (this.props.editable) { if (this.props.editable) {
@ -23,32 +52,46 @@ export default class Editable extends PureComponent {
this.setState({ editing: false }); this.setState({ editing: false });
}; };
handleKey = e => { handleBlur = e => {
if (e.key === 'Enter') { const { onBlur } = this.props;
this.stopEditing(); this.stopEditing();
if (onBlur) {
onBlur(e.target.value);
} }
}; };
handleChange = e => this.props.onChange(e.target.value); handleChange = e => this.props.onChange(e.target.value);
handleKey = e => {
if (e.key === 'Enter') {
this.handleBlur(e);
}
};
inputRef = el => { this.input = el; }
render() { render() {
const { children, className, value } = this.props; const { children, className, value } = this.props;
const style = {
width: this.state.width
};
return ( return (
<div onClick={this.startEditing}> this.state.editing ?
{this.state.editing ? <input
<input autoFocus
autoFocus ref={this.inputRef}
className={className} className={className}
style={style} type="text"
type="text" value={value}
value={value} onBlur={this.handleBlur}
onBlur={this.stopEditing} onChange={this.handleChange}
onChange={this.handleChange} onKeyDown={this.handleKey}
onKeyDown={this.handleKey} style={style}
/> : spellCheck={false}
children /> :
} <div onClick={this.startEditing}>{children}</div>
</div>
); );
} }
} }

View File

@ -10,7 +10,7 @@ import { getSelectedMessages, getHasMoreMessages,
runCommand, sendMessage, fetchMessages, addFetchedMessages } from '../state/messages'; runCommand, sendMessage, fetchMessages, addFetchedMessages } from '../state/messages';
import { openPrivateChat, closePrivateChat } from '../state/privateChats'; import { openPrivateChat, closePrivateChat } from '../state/privateChats';
import { getSearch, searchMessages, toggleSearch } from '../state/search'; import { getSearch, searchMessages, toggleSearch } from '../state/search';
import { getCurrentNick, disconnect, setServerName } from '../state/servers'; import { getCurrentNick, disconnect, setNick, setServerName } from '../state/servers';
import { getSelectedTab, select } from '../state/tab'; import { getSelectedTab, select } from '../state/tab';
import { getShowUserList, toggleUserList } from '../state/ui'; import { getShowUserList, toggleUserList } from '../state/ui';
@ -39,6 +39,7 @@ const mapDispatch = dispatch => ({
searchMessages, searchMessages,
select, select,
sendMessage, sendMessage,
setNick,
setServerName, setServerName,
toggleSearch, toggleSearch,
toggleUserList toggleUserList

View File

@ -67,9 +67,9 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
dispatch(broadcast(withReason(`${user} quit`, reason), server, channels)); dispatch(broadcast(withReason(`${user} quit`, reason), server, channels));
}, },
nick(data) { nick({ server, oldNick, newNick }) {
const channels = findChannels(getState(), data.server, data.old); const channels = findChannels(getState(), server, oldNick);
dispatch(broadcast(`${data.old} changed nick to ${data.new}`, data.server, channels)); dispatch(broadcast(`${oldNick} changed nick to ${newNick}`, server, channels));
}, },
topic({ server, channel, topic, nick }) { topic({ server, channel, topic, nick }) {

View File

@ -105,8 +105,8 @@ describe('channel reducer', () => {
state = reducer(state, { state = reducer(state, {
type: actions.socket.NICK, type: actions.socket.NICK,
server: 'srv', server: 'srv',
old: 'nick1', oldNick: 'nick1',
new: 'nick3' newNick: 'nick3'
}); });
expect(state.toJS()).toEqual({ expect(state.toJS()).toEqual({

View File

@ -10,7 +10,8 @@ describe('server reducer', () => {
'127.0.0.1': { '127.0.0.1': {
connected: false, connected: false,
name: '127.0.0.1', name: '127.0.0.1',
nick: 'nick' nick: 'nick',
editedNick: null
} }
}); });
@ -20,7 +21,8 @@ describe('server reducer', () => {
'127.0.0.1': { '127.0.0.1': {
connected: false, connected: false,
name: '127.0.0.1', name: '127.0.0.1',
nick: 'nick' nick: 'nick',
editedNick: null
} }
}); });
@ -32,12 +34,14 @@ describe('server reducer', () => {
'127.0.0.1': { '127.0.0.1': {
connected: false, connected: false,
name: '127.0.0.1', name: '127.0.0.1',
nick: 'nick' nick: 'nick',
editedNick: null
}, },
'127.0.0.2': { '127.0.0.2': {
connected: false, connected: false,
name: 'srv', name: 'srv',
nick: 'nick' nick: 'nick',
editedNick: null
} }
}); });
}); });
@ -74,20 +78,87 @@ describe('server reducer', () => {
}); });
}); });
it('updates the nick on SOCKET_NICK', () => { it('sets editedNick when editing the nick', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {})); let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
state = reducer(state, { state = reducer(state, {
type: actions.socket.NICK, type: actions.SET_NICK,
server: '127.0.0.1', server: '127.0.0.1',
old: 'nick', nick: 'nick2',
new: 'nick2' editing: true
}); });
expect(state.toJS()).toEqual({ expect(state.toJS()).toEqual({
'127.0.0.1': { '127.0.0.1': {
connected: false, connected: false,
name: '127.0.0.1', name: '127.0.0.1',
nick: 'nick2' nick: 'nick',
editedNick: 'nick2'
}
});
});
it('clears editedNick when receiving an empty nick after editing finishes', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
nick: 'nick2',
editing: true
});
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
nick: ''
});
expect(state.toJS()).toEqual({
'127.0.0.1': {
connected: false,
name: '127.0.0.1',
nick: 'nick',
editedNick: null
}
});
});
it('updates the nick on SOCKET_NICK', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
state = reducer(state, {
type: actions.socket.NICK,
server: '127.0.0.1',
oldNick: 'nick',
newNick: 'nick2'
});
expect(state.toJS()).toEqual({
'127.0.0.1': {
connected: false,
name: '127.0.0.1',
nick: 'nick2',
editedNick: null
}
});
});
it('clears editedNick on SOCKET_NICK_FAIL', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
nick: 'nick2',
editing: true
});
state = reducer(state, {
type: actions.socket.NICK_FAIL,
server: '127.0.0.1'
});
expect(state.toJS()).toEqual({
'127.0.0.1': {
connected: false,
name: '127.0.0.1',
nick: 'nick',
editedNick: null
} }
}); });
}); });
@ -115,11 +186,13 @@ describe('server reducer', () => {
'127.0.0.1': { '127.0.0.1': {
name: 'stuff', name: 'stuff',
nick: 'nick', nick: 'nick',
editedNick: null,
connected: true connected: true
}, },
'127.0.0.2': { '127.0.0.2': {
name: 'stuffz', name: 'stuffz',
nick: 'nick2', nick: 'nick2',
editedNick: null,
connected: false connected: false
} }
}); });
@ -136,6 +209,7 @@ describe('server reducer', () => {
'127.0.0.1': { '127.0.0.1': {
name: '127.0.0.1', name: '127.0.0.1',
nick: 'nick', nick: 'nick',
editedNick: null,
connected: true connected: true
} }
}); });

View File

@ -63,6 +63,7 @@ export const socket = createSocketActions([
'join', 'join',
'message', 'message',
'mode', 'mode',
'nick_fail',
'nick', 'nick',
'part', 'part',
'pm', 'pm',

View File

@ -123,18 +123,17 @@ export default createReducer(Map(), {
}); });
}, },
[actions.socket.NICK](state, action) { [actions.socket.NICK](state, { server, oldNick, newNick }) {
const { server } = action;
return state.withMutations(s => { return state.withMutations(s => {
s.get(server).forEach((v, channel) => { s.get(server).forEach((v, channel) => {
s.updateIn([server, channel, 'users'], users => { s.updateIn([server, channel, 'users'], users => {
const i = users.findIndex(user => user.nick === action.old); const i = users.findIndex(user => user.nick === oldNick);
if (i < 0) { if (i < 0) {
return users; return users;
} }
return users.update(i, return users.update(i,
user => updateRenderName(user.set('nick', action.new)) user => updateRenderName(user.set('nick', newNick))
).sort(compareUsers); ).sort(compareUsers);
}); });
}); });

View File

@ -5,8 +5,9 @@ import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions'; import * as actions from './actions';
const Server = Record({ const Server = Record({
nick: null, nick: '',
name: null, editedNick: null,
name: '',
connected: false connected: false
}); });
@ -15,13 +16,19 @@ export const getServers = state => state.servers;
export const getCurrentNick = createSelector( export const getCurrentNick = createSelector(
getServers, getServers,
getSelectedTab, getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'nick'], '') (servers, tab) => {
const editedNick = servers.getIn([tab.server, 'editedNick']);
if (editedNick === null) {
return servers.getIn([tab.server, 'nick']);
}
return editedNick;
}
); );
export const getCurrentServerName = createSelector( export const getCurrentServerName = createSelector(
getServers, getServers,
getSelectedTab, getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'name'], '') (servers, tab) => servers.getIn([tab.server, 'name'])
); );
export default createReducer(Map(), { export default createReducer(Map(), {
@ -44,14 +51,29 @@ export default createReducer(Map(), {
return state.setIn([server, 'name'], name); return state.setIn([server, 'name'], name);
}, },
[actions.socket.NICK](state, action) { [actions.SET_NICK](state, { server, nick, editing }) {
const { server, old } = action; if (editing) {
if (!old || old === state.get(server).nick) { return state.setIn([server, 'editedNick'], nick);
return state.update(server, s => s.set('nick', action.new)); } else if (nick === '') {
return state.setIn([server, 'editedNick'], null);
} }
return state; return state;
}, },
[actions.socket.NICK](state, { server, oldNick, newNick }) {
if (!oldNick || oldNick === state.get(server).nick) {
return state.update(server, s => s
.set('nick', newNick)
.set('editedNick', null)
);
}
return state;
},
[actions.socket.NICK_FAIL](state, { server }) {
return state.setIn([server, 'editedNick'], null);
},
[actions.socket.SERVERS](state, { data }) { [actions.socket.SERVERS](state, { data }) {
if (!data) { if (!data) {
return state; return state;
@ -140,19 +162,27 @@ export function away(message, server) {
}; };
} }
export function setNick(nick, server) { export function setNick(nick, server, editing) {
return { nick = nick.trim().replace(' ', '');
const action = {
type: actions.SET_NICK, type: actions.SET_NICK,
nick, nick,
server, server,
socket: { editing
};
if (!editing && nick !== '') {
action.socket = {
type: 'nick', type: 'nick',
data: { data: {
new: nick, newNick: nick,
server server
} }
} };
}; }
return action;
} }
export function isValidServerName(name) { export function isValidServerName(name) {

View File

@ -11,27 +11,28 @@ const (
Topic = "TOPIC" Topic = "TOPIC"
Quit = "QUIT" Quit = "QUIT"
ReplyWelcome = "001" ReplyWelcome = "001"
ReplyYourHost = "002" ReplyYourHost = "002"
ReplyCreated = "003" ReplyCreated = "003"
ReplyLUserClient = "251" ReplyLUserClient = "251"
ReplyLUserOp = "252" ReplyLUserOp = "252"
ReplyLUserUnknown = "253" ReplyLUserUnknown = "253"
ReplyLUserChannels = "254" ReplyLUserChannels = "254"
ReplyLUserMe = "255" ReplyLUserMe = "255"
ReplyAway = "301" ReplyAway = "301"
ReplyWhoisUser = "311" ReplyWhoisUser = "311"
ReplyWhoisServer = "312" ReplyWhoisServer = "312"
ReplyWhoisOperator = "313" ReplyWhoisOperator = "313"
ReplyWhoisIdle = "317" ReplyWhoisIdle = "317"
ReplyEndOfWhois = "318" ReplyEndOfWhois = "318"
ReplyWhoisChannels = "319" ReplyWhoisChannels = "319"
ReplyNoTopic = "331" ReplyNoTopic = "331"
ReplyTopic = "332" ReplyTopic = "332"
ReplyNamReply = "353" ReplyNamReply = "353"
ReplyEndOfNames = "366" ReplyEndOfNames = "366"
ReplyMotd = "372" ReplyMotd = "372"
ReplyMotdStart = "375" ReplyMotdStart = "375"
ReplyEndOfMotd = "376" ReplyEndOfMotd = "376"
ErrNicknameInUse = "433" ErrErroneousNickname = "432"
ErrNicknameInUse = "433"
) )

View File

@ -273,35 +273,42 @@ func (i *ircHandler) motdEnd(msg *irc.Message) {
i.motdBuffer = MOTD{} i.motdBuffer = MOTD{}
} }
func (i *ircHandler) badNick(msg *irc.Message) {
i.session.sendJSON("nick_fail", NickFail{
Server: i.client.Host,
})
}
func (i *ircHandler) initHandlers() { func (i *ircHandler) initHandlers() {
i.handlers = map[string]func(*irc.Message){ i.handlers = map[string]func(*irc.Message){
irc.Nick: i.nick, irc.Nick: i.nick,
irc.Join: i.join, irc.Join: i.join,
irc.Part: i.part, irc.Part: i.part,
irc.Mode: i.mode, irc.Mode: i.mode,
irc.Privmsg: i.message, irc.Privmsg: i.message,
irc.Notice: i.message, irc.Notice: i.message,
irc.Quit: i.quit, irc.Quit: i.quit,
irc.Topic: i.topic, irc.Topic: i.topic,
irc.ReplyWelcome: i.info, irc.ReplyWelcome: i.info,
irc.ReplyYourHost: i.info, irc.ReplyYourHost: i.info,
irc.ReplyCreated: i.info, irc.ReplyCreated: i.info,
irc.ReplyLUserClient: i.info, irc.ReplyLUserClient: i.info,
irc.ReplyLUserOp: i.info, irc.ReplyLUserOp: i.info,
irc.ReplyLUserUnknown: i.info, irc.ReplyLUserUnknown: i.info,
irc.ReplyLUserChannels: i.info, irc.ReplyLUserChannels: i.info,
irc.ReplyLUserMe: i.info, irc.ReplyLUserMe: i.info,
irc.ReplyWhoisUser: i.whoisUser, irc.ReplyWhoisUser: i.whoisUser,
irc.ReplyWhoisServer: i.whoisServer, irc.ReplyWhoisServer: i.whoisServer,
irc.ReplyWhoisChannels: i.whoisChannels, irc.ReplyWhoisChannels: i.whoisChannels,
irc.ReplyEndOfWhois: i.whoisEnd, irc.ReplyEndOfWhois: i.whoisEnd,
irc.ReplyNoTopic: i.noTopic, irc.ReplyNoTopic: i.noTopic,
irc.ReplyTopic: i.topic, irc.ReplyTopic: i.topic,
irc.ReplyNamReply: i.names, irc.ReplyNamReply: i.names,
irc.ReplyEndOfNames: i.namesEnd, irc.ReplyEndOfNames: i.namesEnd,
irc.ReplyMotdStart: i.motdStart, irc.ReplyMotdStart: i.motdStart,
irc.ReplyMotd: i.motd, irc.ReplyMotd: i.motd,
irc.ReplyEndOfMotd: i.motdEnd, irc.ReplyEndOfMotd: i.motdEnd,
irc.ErrErroneousNickname: i.badNick,
} }
} }

View File

@ -304,3 +304,21 @@ func TestHandleIRCMotd(t *testing.T) {
Content: []string{"line 1", "line 2"}, Content: []string{"line 1", "line 2"},
}, <-s.broadcast) }, <-s.broadcast)
} }
func TestHandleIRCBadNick(t *testing.T) {
c := irc.NewClient("nick", "user")
c.Host = "host.com"
s := NewSession(nil)
i := newIRCHandler(c, s)
i.dispatchMessage(&irc.Message{
Command: irc.ErrErroneousNickname,
})
// It should print the error message first
<-s.broadcast
checkResponse(t, "nick_fail", NickFail{
Server: "host.com",
}, <-s.broadcast)
}

View File

@ -28,8 +28,12 @@ type Connect struct {
type Nick struct { type Nick struct {
Server string `json:"server"` Server string `json:"server"`
Old string `json:"old"` Old string `json:"oldNick"`
New string `json:"new"` New string `json:"newNick"`
}
type NickFail struct {
Server string `json:"server"`
} }
type Join struct { type Join struct {