Add new connect form, closes #7

This commit is contained in:
Ken-Håvard Lieng 2018-05-16 05:02:48 +02:00
parent f502fea5c1
commit 6fd5235ec9
16 changed files with 524 additions and 231 deletions

File diff suppressed because one or more lines are too long

View File

@ -32,7 +32,7 @@ function brotli(opts) {
function clean() {
return del(['dist']);
};
}
function js(cb) {
var config = require('./webpack.config.prod.js');
@ -131,7 +131,10 @@ const assets = gulp.parallel(js, config, fonts, compressTTF);
const build = gulp.series(clean, assets, compress, cleanup, bindata);
const dev = gulp.series(clean, gulp.parallel(serve, fonts, gulp.series(config, bindata)));
const dev = gulp.series(
clean,
gulp.parallel(serve, fonts, gulp.series(config, bindata))
);
gulp.task('build', build);
gulp.task('default', dev);

View File

@ -53,6 +53,62 @@ i[class*=' icon-']:before {
color: #f6546a !important;
}
.textinput {
display: block;
position: relative;
}
.textinput input {
padding: 17.5px 15px;
}
.textinput input:focus,
.textinput input.value {
padding-top: 25px;
padding-bottom: 10px;
}
.textinput span {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
transform: translateZ(0);
transition: all 0.2s;
color: #777;
}
.textinput-1 {
font: 12px 'Montserrat', sans-serif;
margin: 15px;
opacity: 0;
transform: translateY(10px);
}
.textinput input:focus + .textinput-1,
.textinput-1.value {
opacity: 1;
transform: translateY(0);
}
.textinput-2 {
margin: 22.5px 15px;
}
.textinput input:focus + .textinput-1 + .textinput-2,
.textinput-2.value {
opacity: 0;
transform: translateY(10px);
}
.form-error {
font-family: 'Montserrat', sans-serif;
background: #f6546a;
color: #fff;
padding: 6px 15px;
font-size: 14px;
}
.wrap {
position: fixed;
top: 0;
@ -214,9 +270,10 @@ i[class*=' icon-']:before {
}
.connect-form {
margin: auto 0;
margin: auto 20px;
padding-top: 20px;
width: 300px;
width: 350px;
text-align: center;
}
.connect-form h1 {
@ -235,9 +292,7 @@ i[class*=' icon-']:before {
}
.connect-form input {
display: block;
margin: 5px 0px;
padding: 15px;
margin-top: 5px;
border: none;
}
@ -249,6 +304,7 @@ i[class*=' icon-']:before {
.connect-form input[type='submit'] {
height: 50px;
margin-top: 0;
margin-bottom: 20px;
font-family: Montserrat, sans-serif;
background: #6bb758;
@ -264,28 +320,47 @@ i[class*=' icon-']:before {
background: #6bb758;
}
.connect-form input[type='checkbox'] {
display: inline-block;
margin-right: 5px;
vertical-align: middle;
.connect-form-address {
display: flex;
}
.connect-form-address .textinput:nth-child(1) {
flex: 1;
}
.connect-form-address .textinput:nth-child(2) {
width: 65px;
}
.connect-form-address input {
padding-right: 0;
}
.connect-form-address label {
margin-top: 5px;
font: 12px 'Montserrat', sans-serif;
padding: 10px;
padding-bottom: 0;
text-align: center;
background: #fff;
color: #777;
}
.connect-form-address input[type='checkbox'] {
margin-top: 5px;
}
.connect-form i {
float: right;
display: block;
cursor: pointer;
color: #999;
padding: 10px 5px;
text-align: center;
font-size: 24px;
padding: 5px 0;
}
.connect-form i:hover {
color: #000;
}
.connect-form label {
display: inline-block;
padding: 10px 0;
color: #333;
color: #666;
}
.chat-title-bar {

View File

@ -1,144 +1,94 @@
import React, { Component } from 'react';
import { createSelector } from 'reselect';
import { Form, withFormik } from 'formik';
import Navicon from 'containers/Navicon';
import Checkbox from 'components/ui/Checkbox';
import TextInput from 'components/ui/TextInput';
import { isValidNick, isValidChannel, isValidUsername } from 'utils';
const getSortedDefaultChannels = createSelector(
defaults => defaults.channels,
channels => channels.concat().sort()
channels => channels.split(',').sort()
);
export default class Connect extends Component {
const Error = ({ children }) =>
children ? <div className="form-error">{children}</div> : null;
class Connect extends Component {
state = {
showOptionals: false,
passwordTouched: false
showOptionals: false
};
handleSubmit = e => {
const { connect, select, join, defaults } = this.props;
e.preventDefault();
const nick = e.target.nick.value.trim();
let { address, channels } = defaults;
const opts = {
name: defaults.name
};
if (!defaults.readonly) {
address = e.target.address.value.trim();
channels = e.target.channels.value
.split(',')
.map(s => s.trim())
.filter(s => s);
opts.name = e.target.name.value.trim();
opts.tls = e.target.ssl.checked;
if (this.state.showOptionals) {
opts.realname = e.target.realname.value.trim();
opts.username = e.target.username.value.trim();
if (this.state.passwordTouched) {
opts.password = e.target.password.value.trim();
}
}
}
if (address.indexOf('.') > 0 && nick) {
connect(address, nick, opts);
const i = address.indexOf(':');
if (i > 0) {
address = address.slice(0, i);
}
select(address);
if (channels.length > 0) {
join(channels, address);
}
handleSSLChange = e => {
const { values, setFieldValue } = this.props;
if (e.target.checked && values.port === '6667') {
setFieldValue('port', '6697', false);
} else if (!e.target.checked && values.port === '6697') {
setFieldValue('port', '6667', false);
}
setFieldValue('ssl', e.target.checked);
};
handleShowClick = () => {
this.setState({ showOptionals: !this.state.showOptionals });
};
handlePasswordChange = () => {
this.setState({ passwordTouched: true });
renderOptionals = () => {
const { errors, touched } = this.props;
return (
<div>
<TextInput name="username" placeholder="Username" />
{touched.username && <Error>{errors.username}</Error>}
<TextInput type="password" name="password" placeholder="Password" />
<TextInput name="realname" placeholder="Realname" />
</div>
);
};
render() {
const { defaults } = this.props;
let optionals = null;
if (this.state.showOptionals) {
optionals = (
<div>
<input name="username" type="text" placeholder="Username" />
<input
name="password"
type="password"
placeholder="Password"
defaultValue={defaults.password ? ' ' : null}
onChange={this.handlePasswordChange}
/>
<input name="realname" type="text" placeholder="Realname" />
</div>
);
}
const { defaults, values, errors, touched } = this.props;
const { readonly, showDetails } = defaults;
let form;
if (defaults.readonly) {
if (readonly) {
form = (
<form className="connect-form" onSubmit={this.handleSubmit}>
<Form className="connect-form">
<h1>Connect</h1>
{defaults.showDetails && (
{showDetails && (
<div className="connect-details">
<h2>{defaults.address}</h2>
{getSortedDefaultChannels(defaults).map(channel => (
<h2>
{values.host}:{values.port}
</h2>
{getSortedDefaultChannels(values).map(channel => (
<p>{channel}</p>
))}
</div>
)}
<input name="nick" type="text" placeholder="Nick" />
<TextInput name="nick" placeholder="Nick" />
{touched.nick && <Error>{errors.nick}</Error>}
<input type="submit" value="Connect" />
</form>
</Form>
);
} else {
form = (
<form className="connect-form" onSubmit={this.handleSubmit}>
<Form className="connect-form">
<h1>Connect</h1>
<input
name="name"
type="text"
placeholder="Name"
defaultValue={defaults.name}
/>
<input
name="address"
type="text"
placeholder="Address"
defaultValue={defaults.address}
/>
<input name="nick" type="text" placeholder="Nick" />
<input
name="channels"
type="text"
placeholder="Channels"
defaultValue={
defaults.channels ? defaults.channels.join(',') : null
}
/>
{optionals}
<p>
<label htmlFor="ssl">
<input name="ssl" type="checkbox" defaultChecked={defaults.ssl} />SSL
</label>
<TextInput name="name" placeholder="Name" />
<div className="connect-form-address">
<TextInput name="host" placeholder="Host" />
<TextInput name="port" placeholder="Port" />
<Checkbox name="ssl" label="SSL" onChange={this.handleSSLChange} />
</div>
{touched.host && <Error>{errors.host}</Error>}
{touched.port && <Error>{errors.port}</Error>}
<TextInput name="nick" placeholder="Nick" />
{touched.nick && <Error>{errors.nick}</Error>}
<TextInput name="channels" placeholder="Channels" />
{touched.channels && <Error>{errors.channels}</Error>}
{this.state.showOptionals && this.renderOptionals()}
<i className="icon-ellipsis" onClick={this.handleShowClick} />
</p>
<input type="submit" value="Connect" />
</form>
</Form>
);
}
@ -150,3 +100,80 @@ export default class Connect extends Component {
);
}
}
export default withFormik({
mapPropsToValues: ({ defaults }) => ({
name: defaults.name,
host: defaults.host,
port: defaults.port || defaults.ssl ? '6697' : '6667',
nick: '',
channels: defaults.channels.join(','),
username: '',
password: defaults.password ? ' ' : '',
realname: '',
ssl: defaults.ssl
}),
validate: values => {
Object.keys(values).forEach(k => {
if (typeof values[k] === 'string') {
values[k] = values[k].trim();
}
});
const errors = {};
if (!values.host) {
errors.host = 'Host is required';
} else if (values.host.indexOf('.') < 1) {
errors.host = 'Invalid host';
}
if (!values.port) {
values.port = values.ssl ? '6697' : '6667';
} else if (values.port < 1 || values.port > 65535) {
errors.port = 'Invalid port';
}
if (!values.nick) {
errors.nick = 'Nick is required';
} else if (!isValidNick(values.nick)) {
errors.nick = 'Invalid nick';
}
if (values.username && !isValidUsername(values.username)) {
errors.username = 'Invalid username';
}
values.channels = values.channels
.split(',')
.map(channel => {
channel = channel.trim();
if (channel) {
if (isValidChannel(channel, false)) {
if (channel[0] !== '#') {
channel = `#${channel}`;
}
} else {
errors.channels = 'Invalid channel(s)';
}
}
return channel;
})
.filter(s => s)
.join(',');
return errors;
},
handleSubmit: (values, { props }) => {
const { connect, select, join } = props;
const channels = values.channels.split(',');
delete values.channels;
connect(values);
select(values.host);
if (channels.length > 0) {
join(channels, values.host);
}
}
})(Connect);

View File

@ -0,0 +1,29 @@
import React from 'react';
import { Field } from 'formik';
const Checkbox = ({ name, label, onChange, ...props }) => (
<Field
name={name}
render={({ field, form }) => (
<label htmlFor={name}>
{label && <div>{label}</div>}
<input
type="checkbox"
id={name}
name={name}
checked={field.value}
onChange={e => {
form.setFieldTouched(name, true);
field.onChange(e);
if (onChange) {
onChange(e);
}
}}
{...props}
/>
</label>
)}
/>
);
export default Checkbox;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Field } from 'formik';
const TextInput = ({ name, placeholder, ...props }) => (
<Field
name={name}
render={({ field }) => (
<div className="textinput">
<input
className={field.value ? 'value' : null}
type="text"
name={name}
{...field}
{...props}
/>
<span className={field.value ? 'textinput-1 value' : 'textinput-1'}>
{placeholder}
</span>
<span className={field.value ? 'textinput-2 value' : 'textinput-2'}>
{placeholder}
</span>
</div>
)}
/>
);
export default TextInput;

View File

@ -1,27 +1,5 @@
import { connect, setServerName } from '../servers';
describe('connect()', () => {
it('sets host and port correctly', () => {
expect(connect('cake.com:1881', '', {})).toMatchObject({
socket: {
data: {
host: 'cake.com',
port: '1881'
}
}
});
expect(connect('cake.com', '', {})).toMatchObject({
socket: {
data: {
host: 'cake.com',
port: undefined
}
}
});
});
});
describe('setServerName()', () => {
it('passes valid names to the server', () => {
const name = 'cake';

View File

@ -232,7 +232,10 @@ describe('channel reducer', () => {
});
it('optimistically adds the server on CONNECT', () => {
const state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
const state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
expect(state).toEqual({
'127.0.0.1': {}

View File

@ -3,7 +3,10 @@ import * as actions from '../actions';
describe('server reducer', () => {
it('adds the server on CONNECT', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
expect(state).toEqual({
'127.0.0.1': {
@ -17,7 +20,7 @@ describe('server reducer', () => {
}
});
state = reducer(state, connect('127.0.0.1:1337', 'nick', {}));
state = reducer(state, connect({ host: '127.0.0.1', nick: 'nick' }));
expect(state).toEqual({
'127.0.0.1': {
@ -33,9 +36,7 @@ describe('server reducer', () => {
state = reducer(
state,
connect('127.0.0.2:1337', 'nick', {
name: 'srv'
})
connect({ host: '127.0.0.2', nick: 'nick', name: 'srv' })
);
expect(state).toEqual({
@ -93,7 +94,10 @@ describe('server reducer', () => {
});
it('sets editedNick when editing the nick', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
@ -111,7 +115,10 @@ describe('server reducer', () => {
});
it('clears editedNick when receiving an empty nick after editing finishes', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
@ -134,7 +141,10 @@ describe('server reducer', () => {
});
it('updates the nick on SOCKET_NICK', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.socket.NICK,
server: '127.0.0.1',
@ -152,7 +162,10 @@ describe('server reducer', () => {
});
it('clears editedNick on SOCKET_NICK_FAIL', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
@ -200,6 +213,7 @@ describe('server reducer', () => {
'127.0.0.1': {
name: 'stuff',
nick: 'nick',
editedNick: null,
status: {
connected: true
}
@ -207,6 +221,7 @@ describe('server reducer', () => {
'127.0.0.2': {
name: 'stuffz',
nick: 'nick2',
editedNick: null,
status: {
connected: false
}
@ -215,7 +230,10 @@ describe('server reducer', () => {
});
it('updates connection status on SOCKET_CONNECTION_UPDATE', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.socket.CONNECTION_UPDATE,
server: '127.0.0.1',

View File

@ -14,7 +14,7 @@ export const getCurrentNick = createSelector(
return;
}
const { editedNick } = servers[tab.server];
if (!editedNick) {
if (editedNick === null) {
return servers[tab.server].nick;
}
return editedNick;
@ -36,12 +36,12 @@ export const getCurrentServerStatus = createSelector(
export default createReducer(
{},
{
[actions.CONNECT](state, { host, nick, options }) {
[actions.CONNECT](state, { host, nick, name }) {
if (!state[host]) {
state[host] = {
nick,
editedNick: null,
name: options.name || host,
name: name || host,
status: {
connected: false,
error: null
@ -82,7 +82,7 @@ export default createReducer(
[actions.socket.SERVERS](state, { data }) {
if (data) {
data.forEach(({ host, name, nick, status }) => {
state[host] = { name, nick, status };
state[host] = { name, nick, status, editedNick: null };
});
}
},
@ -96,32 +96,13 @@ export default createReducer(
}
);
export function connect(server, nick, options) {
let host = server;
let port;
const i = server.indexOf(':');
if (i > 0) {
host = server.slice(0, i);
port = server.slice(i + 1);
}
export function connect(config) {
return {
type: actions.CONNECT,
host,
nick,
options,
...config,
socket: {
type: 'connect',
data: {
host,
port,
nick,
username: options.username || nick,
password: options.password,
realname: options.realname || nick,
tls: options.tls || false,
name: options.name || server
}
data: config
}
};
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { isChannel } from '..';
import { isChannel, isValidNick, isValidChannel, isValidUsername } from '..';
import linkify from '../linkify';
describe('isChannel()', () => {
@ -14,6 +14,68 @@ describe('isChannel()', () => {
});
});
describe('isValidNick()', () => {
it('validates nicks', () =>
Object.entries({
bob: true,
'bob likes cake': false,
'-bob': false,
'bob.': false,
'bob-': true,
'1bob': false,
'[bob}': true,
'': false,
' ': false
}).forEach(([input, expected]) =>
expect(isValidNick(input)).toBe(expected)
));
});
describe('isValidChannel()', () => {
it('validates channels', () =>
Object.entries({
'#chan': true,
'#cak e': false,
'#cake:': false,
'#[cake]': true,
'#ca,ke': false,
'': false,
' ': false,
cake: false
}).forEach(([input, expected]) =>
expect(isValidChannel(input)).toBe(expected)
));
it('handles requirePrefix', () =>
Object.entries({
chan: true,
'cak e': false,
'#cake:': false,
'#[cake]': true,
'#ca,ke': false
}).forEach(([input, expected]) =>
expect(isValidChannel(input, false)).toBe(expected)
));
});
describe('isValidUsername()', () => {
it('validates usernames', () =>
Object.entries({
bob: true,
'bob likes cake': false,
'-bob': true,
'bob.': true,
'bob-': true,
'1bob': true,
'[bob}': true,
'': false,
' ': false,
'b@b': false
}).forEach(([input, expected]) =>
expect(isValidUsername(input)).toBe(expected)
));
});
describe('linkify()', () => {
const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href);
const linkTo = href => (

View File

@ -2,7 +2,7 @@ if (Object.keys) {
try {
Object.keys('');
} catch (e) {
Object.keys = function(o, k, r) {
Object.keys = function keys(o, k, r) {
r = [];
// eslint-disable-next-line
for (k in o) r.hasOwnProperty.call(o, k) && r.push(k);

View File

@ -35,6 +35,93 @@ export function stringifyTab(server, name) {
return server;
}
function isString(s, maxLength) {
if (!s || typeof s !== 'string') {
return false;
}
if (maxLength && s.length > maxLength) {
return false;
}
return true;
}
// RFC 2812
// nickname = ( letter / special ) *( letter / digit / special / "-" )
// letter = A-Z / a-z
// digit = 0-9
// special = "[", "]", "\", "`", "_", "^", "{", "|", "}"
export function isValidNick(nick, maxLength = 30) {
if (!isString(nick, maxLength)) {
return false;
}
for (let i = 0; i < nick.length; i++) {
const char = nick.charCodeAt(i);
if (
(i > 0 && char < 45) ||
(char > 45 && char < 48) ||
(char > 57 && char < 65) ||
char > 125
) {
return false;
} else if ((i === 0 && char < 65) || char > 125) {
return false;
}
}
return true;
}
// chanstring = any octet except NUL, BELL, CR, LF, " ", "," and ":"
export function isValidChannel(channel, requirePrefix = true) {
if (!isString(channel)) {
return false;
}
if (requirePrefix && channel[0] !== '#') {
return false;
}
for (let i = 0; i < channel.length; i++) {
const char = channel.charCodeAt(i);
if (
char === 0 ||
char === 7 ||
char === 10 ||
char === 13 ||
char === 32 ||
char === 44 ||
char === 58
) {
return false;
}
}
return true;
}
// user = any octet except NUL, CR, LF, " " and "@"
export function isValidUsername(username) {
if (!isString(username)) {
return false;
}
for (let i = 0; i < username.length; i++) {
const char = username.charCodeAt(i);
if (
char === 0 ||
char === 10 ||
char === 13 ||
char === 32 ||
char === 64
) {
return false;
}
}
return true;
}
export function timestamp(date = new Date()) {
const h = padStart(date.getHours(), 2, '0');
const m = padStart(date.getMinutes(), 2, '0');

View File

@ -4,7 +4,8 @@ verify_certificates = true
# Defaults for the client connect form
[defaults]
name = "Freenode"
address = "chat.freenode.net"
host = "chat.freenode.net"
port = 6697
channels = [
"#dispatch",
"#go-nuts"

View File

@ -11,9 +11,10 @@ import (
)
type connectDefaults struct {
Name string `json:"name"`
Address string `json:"address"`
Channels []string `json:"channels"`
Name string `json:"name,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Channels []string `json:"channels,omitempty"`
Password bool `json:"password,omitempty"`
SSL bool `json:"ssl,omitempty"`
ReadOnly bool `json:"readonly,omitempty"`
@ -81,7 +82,8 @@ func getIndexData(r *http.Request, session *Session) *indexData {
data.Defaults = connectDefaults{
Name: viper.GetString("defaults.name"),
Address: viper.GetString("defaults.address"),
Host: viper.GetString("defaults.host"),
Port: viper.GetInt("defaults.port"),
Channels: viper.GetStringSlice("defaults.channels"),
Password: viper.GetString("defaults.password") != "",
SSL: viper.GetBool("defaults.ssl"),

View File

@ -85,7 +85,7 @@ func (h *wsHandler) connect(b []byte) {
json.Unmarshal(b, &data)
if _, ok := h.session.getIRC(data.Host); !ok {
log.Println(h.addr, "[IRC] Add server", data.Server)
log.Println(h.addr, "[IRC] Add server", data.Host)
connectIRC(data.Server, h.session)