Support changing the server name by clicking it in the status tab

This commit is contained in:
Ken-Håvard Lieng 2017-06-12 06:18:32 +02:00
parent 3b33957161
commit b639ba6846
14 changed files with 259 additions and 56 deletions

File diff suppressed because one or more lines are too long

View File

@ -85,6 +85,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
} }
.tablist p { .tablist p {
height: 30px;
padding: 3px 15px; padding: 3px 15px;
padding-right: 10px; padding-right: 10px;
cursor: pointer; cursor: pointer;
@ -322,7 +323,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
.chat-title { .chat-title {
margin-left: 15px; margin-left: 15px;
font-size: 24px; font-size: 24px !important;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -1,11 +1,14 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { List } from 'immutable'; import { List } from 'immutable';
import Navicon from '../containers/Navicon'; import Navicon from '../containers/Navicon';
import Editable from './ui/Editable';
import { isValidServerName } from '../state/servers';
import { linkify } from '../util'; import { linkify } from '../util';
export default class ChatTitle extends PureComponent { export default class ChatTitle extends PureComponent {
render() { render() {
const { title, tab, channel, onToggleSearch, onToggleUserList, onCloseClick } = this.props; const { title, tab, channel, onTitleChange,
onToggleSearch, onToggleUserList, onCloseClick } = this.props;
let closeTitle; let closeTitle;
if (tab.isChannel()) { if (tab.isChannel()) {
@ -20,7 +23,15 @@ export default class ChatTitle extends PureComponent {
<div> <div>
<div className="chat-title-bar"> <div className="chat-title-bar">
<Navicon /> <Navicon />
<span className="chat-title">{title}</span> <Editable
className="chat-title"
editable={!tab.name}
value={title}
validate={isValidServerName}
onChange={onTitleChange}
>
<span className="chat-title">{title}</span>
</Editable>
<div className="chat-topic-wrap"> <div className="chat-topic-wrap">
<span className="chat-topic">{linkify(channel.get('topic')) || null}</span> <span className="chat-topic">{linkify(channel.get('topic')) || null}</span>
</div> </div>

View File

@ -31,6 +31,11 @@ export default class Chat extends Component {
select(tab.server, nick); select(tab.server, nick);
}; };
handleTitleChange = title => {
const { setServerName, tab } = this.props;
setServerName(title, tab.server);
};
render() { render() {
const { const {
channel, channel,
@ -69,6 +74,7 @@ export default class Chat extends Component {
tab={tab} tab={tab}
title={title} title={title}
onCloseClick={this.handleCloseClick} onCloseClick={this.handleCloseClick}
onTitleChange={this.handleTitleChange}
onToggleSearch={toggleSearch} onToggleSearch={toggleSearch}
onToggleUserList={toggleUserList} onToggleUserList={toggleUserList}
/> />

View File

@ -0,0 +1,54 @@
import React, { PureComponent } from 'react';
const style = {
background: 'none',
font: 'inherit'
};
export default class Editable extends PureComponent {
state = { editing: false };
startEditing = () => {
if (this.props.editable) {
this.initialValue = this.props.value;
this.setState({ editing: true });
}
};
stopEditing = () => {
const { validate, value, onChange } = this.props;
if (validate && !validate(value)) {
onChange(this.initialValue);
}
this.setState({ editing: false });
};
handleKey = e => {
if (e.key === 'Enter') {
this.stopEditing();
}
};
handleChange = e => this.props.onChange(e.target.value);
render() {
const { children, className, value } = this.props;
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>
);
}
}

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 } from '../state/servers'; import { getCurrentNick, disconnect, 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,
setServerName,
toggleSearch, toggleSearch,
toggleUserList toggleUserList
}, dispatch), }, dispatch),

View File

@ -1,9 +1,36 @@
export default function createSocketMiddleware(socket) { import debounce from 'lodash/debounce';
return () => next => action => {
if (action.socket) {
socket.send(action.socket.type, action.socket.data);
}
return next(action); const debounceKey = action => {
const key = action.socket.debounce.key;
if (key) {
return `${action.type} ${key}`;
}
return action.type;
};
export default function createSocketMiddleware(socket) {
return () => next => {
const debounced = {};
return action => {
if (action.socket) {
if (action.socket.debounce) {
const key = debounceKey(action);
if (!debounced[key]) {
debounced[key] = debounce((type, data) => {
socket.send(type, data);
debounced[key] = undefined;
}, action.socket.debounce.delay);
}
debounced[key](action.socket.type, action.socket.data);
} else {
socket.send(action.socket.type, action.socket.data);
}
}
return next(action);
};
}; };
} }

View File

@ -0,0 +1,20 @@
import { setServerName } from '../servers';
describe('setServerName()', () => {
it('passes valid names to the server', () => {
const name = 'cake';
const server = 'srv';
expect(setServerName(name, server)).toMatchObject({
socket: {
type: 'set_server_name',
data: { name, server }
}
});
});
it('does not pass invalid names to the server', () => {
expect(setServerName('', 'srv').socket).toBeUndefined();
expect(setServerName(' ', 'srv').socket).toBeUndefined();
});
});

View File

@ -1,5 +1,5 @@
import Immutable from 'immutable'; import Immutable from 'immutable';
import reducer, { connect } from '../servers'; import reducer, { connect, setServerName } from '../servers';
import * as actions from '../actions'; import * as actions from '../actions';
describe('reducers/servers', () => { describe('reducers/servers', () => {
@ -58,6 +58,22 @@ describe('reducers/servers', () => {
}); });
}); });
it('handles SET_SERVER_NAME', () => {
let state = Immutable.fromJS({
srv: {
name: 'cake'
}
});
state = reducer(state, setServerName('pie', 'srv'));
expect(state.toJS()).toEqual({
srv: {
name: 'pie'
}
});
});
it('updates the nick on SOCKET_NICK', () => { it('updates the nick on SOCKET_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, {

View File

@ -29,6 +29,7 @@ export const AWAY = 'AWAY';
export const CONNECT = 'CONNECT'; export const CONNECT = 'CONNECT';
export const DISCONNECT = 'DISCONNECT'; export const DISCONNECT = 'DISCONNECT';
export const SET_NICK = 'SET_NICK'; export const SET_NICK = 'SET_NICK';
export const SET_SERVER_NAME = 'SET_SERVER_NAME';
export const WHOIS = 'WHOIS'; export const WHOIS = 'WHOIS';
export const SET_CERT = 'SET_CERT'; export const SET_CERT = 'SET_CERT';

View File

@ -25,9 +25,7 @@ export const getCurrentServerName = createSelector(
); );
export default createReducer(Map(), { export default createReducer(Map(), {
[actions.CONNECT](state, action) { [actions.CONNECT](state, { host, nick, options }) {
const { host, nick, options } = action;
if (!state.has(host)) { if (!state.has(host)) {
return state.set(host, new Server({ return state.set(host, new Server({
nick, nick,
@ -38,8 +36,12 @@ export default createReducer(Map(), {
return state; return state;
}, },
[actions.DISCONNECT](state, action) { [actions.DISCONNECT](state, { server }) {
return state.delete(action.server); return state.delete(server);
},
[actions.SET_SERVER_NAME](state, { server, name }) {
return state.setIn([server, 'name'], name);
}, },
[actions.socket.NICK](state, action) { [actions.socket.NICK](state, action) {
@ -50,13 +52,13 @@ export default createReducer(Map(), {
return state; return state;
}, },
[actions.socket.SERVERS](state, action) { [actions.socket.SERVERS](state, { data }) {
if (!action.data) { if (!data) {
return state; return state;
} }
return state.withMutations(s => { return state.withMutations(s => {
action.data.forEach(server => { data.forEach(server => {
s.set(server.host, new Server(server)); s.set(server.host, new Server(server));
}); });
}); });
@ -152,3 +154,31 @@ export function setNick(nick, server) {
} }
}; };
} }
export function isValidServerName(name) {
return name.trim() !== '';
}
export function setServerName(name, server) {
const action = {
type: actions.SET_SERVER_NAME,
name,
server
};
if (isValidServerName(name)) {
action.socket = {
type: 'set_server_name',
data: {
name,
server
},
debounce: {
delay: 1000,
key: server
}
};
}
return action;
}

View File

@ -283,22 +283,36 @@ func (h *wsHandler) fetchMessages(b []byte) {
h.session.sendMessages(data.Server, data.Channel, 200, data.Next) h.session.sendMessages(data.Server, data.Channel, 200, data.Next)
} }
func (h *wsHandler) initHandlers() { func (h *wsHandler) setServerName(b []byte) {
h.handlers = map[string]func([]byte){ var data Connect
"connect": h.connect, json.Unmarshal(b, &data)
"join": h.join,
"part": h.part, if isValidServerName(data.Name) {
"quit": h.quit, h.session.user.SetServerName(data.Name, data.Server)
"message": h.message,
"nick": h.nick,
"topic": h.topic,
"invite": h.invite,
"kick": h.kick,
"whois": h.whois,
"away": h.away,
"raw": h.raw,
"search": h.search,
"cert": h.cert,
"fetch_messages": h.fetchMessages,
} }
} }
func (h *wsHandler) initHandlers() {
h.handlers = map[string]func([]byte){
"connect": h.connect,
"join": h.join,
"part": h.part,
"quit": h.quit,
"message": h.message,
"nick": h.nick,
"topic": h.topic,
"invite": h.invite,
"kick": h.kick,
"whois": h.whois,
"away": h.away,
"raw": h.raw,
"search": h.search,
"cert": h.cert,
"fetch_messages": h.fetchMessages,
"set_server_name": h.setServerName,
}
}
func isValidServerName(name string) bool {
return strings.TrimSpace(name) != ""
}

View File

@ -178,6 +178,25 @@ func (u *User) SetNick(nick, address string) {
}) })
} }
func (u *User) SetServerName(name, address string) {
db.Batch(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketServers)
id := u.serverID(address)
server := Server{}
v := b.Get(id)
if v != nil {
server.Unmarshal(v)
server.Name = name
data, _ := server.Marshal(nil)
b.Put(id, data)
}
return nil
})
}
func (u *User) RemoveServer(address string) { func (u *User) RemoveServer(address string) {
db.Batch(func(tx *bolt.Tx) error { db.Batch(func(tx *bolt.Tx) error {
serverID := u.serverID(address) serverID := u.serverID(address)

View File

@ -63,6 +63,9 @@ func TestUser(t *testing.T) {
user.SetNick("bob", srv.Host) user.SetNick("bob", srv.Host)
assert.Equal(t, "bob", user.GetServers()[0].Nick) assert.Equal(t, "bob", user.GetServers()[0].Nick)
user.SetServerName("cake", srv.Host)
assert.Equal(t, "cake", user.GetServers()[0].Name)
user.RemoveChannel(srv.Host, chan1.Name) user.RemoveChannel(srv.Host, chan1.Name)
channels = user.GetChannels() channels = user.GetChannels()
assert.Len(t, channels, 1) assert.Len(t, channels, 1)