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

View File

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

View File

@ -36,6 +36,16 @@ export default class Chat extends Component {
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() {
const {
channel,
@ -96,6 +106,8 @@ export default class Chat extends Component {
tab={tab}
onCommand={runCommand}
onMessage={sendMessage}
onNickChange={this.handleNickChange}
onNickEditDone={this.handleNickEditDone}
{...inputActions}
/>
<UserList

View File

@ -1,12 +1,41 @@
import React, { PureComponent } from 'react';
const style = {
background: 'none',
font: 'inherit'
};
import { stringWidth } from '../../util';
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 = () => {
if (this.props.editable) {
@ -23,32 +52,46 @@ export default class Editable extends PureComponent {
this.setState({ editing: false });
};
handleKey = e => {
if (e.key === 'Enter') {
this.stopEditing();
handleBlur = e => {
const { onBlur } = this.props;
this.stopEditing();
if (onBlur) {
onBlur(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() {
const { children, className, value } = this.props;
const style = {
width: this.state.width
};
return (
<div onClick={this.startEditing}>
{this.state.editing ?
<input
autoFocus
className={className}
style={style}
type="text"
value={value}
onBlur={this.stopEditing}
onChange={this.handleChange}
onKeyDown={this.handleKey}
/> :
children
}
</div>
this.state.editing ?
<input
autoFocus
ref={this.inputRef}
className={className}
type="text"
value={value}
onBlur={this.handleBlur}
onChange={this.handleChange}
onKeyDown={this.handleKey}
style={style}
spellCheck={false}
/> :
<div onClick={this.startEditing}>{children}</div>
);
}
}

View File

@ -10,7 +10,7 @@ import { getSelectedMessages, getHasMoreMessages,
runCommand, sendMessage, fetchMessages, addFetchedMessages } from '../state/messages';
import { openPrivateChat, closePrivateChat } from '../state/privateChats';
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 { getShowUserList, toggleUserList } from '../state/ui';
@ -39,6 +39,7 @@ const mapDispatch = dispatch => ({
searchMessages,
select,
sendMessage,
setNick,
setServerName,
toggleSearch,
toggleUserList

View File

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

View File

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

View File

@ -10,7 +10,8 @@ describe('server reducer', () => {
'127.0.0.1': {
connected: false,
name: '127.0.0.1',
nick: 'nick'
nick: 'nick',
editedNick: null
}
});
@ -20,7 +21,8 @@ describe('server reducer', () => {
'127.0.0.1': {
connected: false,
name: '127.0.0.1',
nick: 'nick'
nick: 'nick',
editedNick: null
}
});
@ -32,12 +34,14 @@ describe('server reducer', () => {
'127.0.0.1': {
connected: false,
name: '127.0.0.1',
nick: 'nick'
nick: 'nick',
editedNick: null
},
'127.0.0.2': {
connected: false,
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', {}));
state = reducer(state, {
type: actions.socket.NICK,
type: actions.SET_NICK,
server: '127.0.0.1',
old: 'nick',
new: 'nick2'
nick: 'nick2',
editing: true
});
expect(state.toJS()).toEqual({
'127.0.0.1': {
connected: false,
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': {
name: 'stuff',
nick: 'nick',
editedNick: null,
connected: true
},
'127.0.0.2': {
name: 'stuffz',
nick: 'nick2',
editedNick: null,
connected: false
}
});
@ -136,6 +209,7 @@ describe('server reducer', () => {
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
connected: true
}
});

View File

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

View File

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

View File

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

View File

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

View File

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