Add manifest.json, icons and install button, flatten client/src

This commit is contained in:
Ken-Håvard Lieng 2018-11-10 12:18:45 +01:00
parent a219e689c1
commit 474afda9c2
105 changed files with 338 additions and 283 deletions

View file

@ -0,0 +1,77 @@
import React, { Suspense, lazy } from 'react';
import Route from 'containers/Route';
import AppInfo from 'components/AppInfo';
import TabList from 'components/TabList';
import cn from 'classnames';
const Chat = lazy(() => import('containers/Chat'));
const Connect = lazy(() => import('containers/Connect'));
const Settings = lazy(() => import('containers/Settings'));
const App = ({
connected,
tab,
channels,
servers,
privateChats,
showTabList,
select,
push,
hideMenu,
newVersionAvailable
}) => {
const mainClass = cn('main-container', {
'off-canvas': showTabList
});
const handleClick = () => {
if (showTabList) {
hideMenu();
}
};
return (
<div className="wrap" onClick={handleClick}>
{!connected && (
<AppInfo type="error">
Connection lost, attempting to reconnect...
</AppInfo>
)}
{newVersionAvailable && (
<AppInfo dismissible>
A new version of dispatch just got installed, reload to start using
it!
</AppInfo>
)}
<div className="app-container">
<TabList
tab={tab}
channels={channels}
servers={servers}
privateChats={privateChats}
showTabList={showTabList}
select={select}
push={push}
/>
<div className={mainClass}>
<Suspense
maxDuration={1000}
fallback={<div className="suspense-fallback">...</div>}
>
<Route name="chat">
<Chat />
</Route>
<Route name="connect">
<Connect />
</Route>
<Route name="settings">
<Settings />
</Route>
</Suspense>
</div>
</div>
</div>
);
};
export default App;

View file

@ -0,0 +1,28 @@
import React, { useState } from 'react';
import cn from 'classnames';
const AppInfo = ({ type, children, dismissible }) => {
const [dismissed, setDismissed] = useState(false);
if (!dismissed) {
const handleDismiss = () => {
if (dismissible) {
setDismissed(true);
}
};
const className = cn('app-info', {
[`app-info-${type}`]: type
});
return (
<div className={className} onClick={handleDismiss}>
{children}
</div>
);
}
return null;
};
export default AppInfo;

View file

@ -0,0 +1,12 @@
import React from 'react';
import { Provider } from 'react-redux';
import { hot } from 'react-hot-loader';
import App from 'containers/App';
const Root = ({ store }) => (
<Provider store={store}>
<App />
</Provider>
);
export default hot(module)(Root);

View file

@ -0,0 +1,81 @@
import React, { PureComponent } from 'react';
import classnames from 'classnames';
import Button from 'components/ui/Button';
import TabListItem from './TabListItem';
export default class TabList extends PureComponent {
handleTabClick = (server, target) => this.props.select(server, target);
handleConnectClick = () => this.props.push('/connect');
handleSettingsClick = () => this.props.push('/settings');
render() {
const { tab, channels, servers, privateChats, showTabList } = this.props;
const tabs = [];
const className = classnames('tablist', {
'off-canvas': showTabList
});
channels.forEach(server => {
const { address } = server;
const srv = servers[address];
tabs.push(
<TabListItem
key={address}
server={address}
content={srv.name}
selected={tab.server === address && !tab.name}
connected={srv.status.connected}
onClick={this.handleTabClick}
/>
);
server.channels.forEach(name =>
tabs.push(
<TabListItem
key={address + name}
server={address}
target={name}
content={name}
selected={tab.server === address && tab.name === name}
onClick={this.handleTabClick}
/>
)
);
if (privateChats[address] && privateChats[address].length > 0) {
tabs.push(
<div key={`${address}-pm}`} className="tab-label">
Private messages
</div>
);
privateChats[address].forEach(nick =>
tabs.push(
<TabListItem
key={address + nick}
server={address}
target={nick}
content={nick}
selected={tab.server === address && tab.name === nick}
onClick={this.handleTabClick}
/>
)
);
}
});
return (
<div className={className}>
<div className="tab-container">{tabs}</div>
<div className="side-buttons">
<Button onClick={this.handleConnectClick}>+</Button>
<i className="icon-user" />
<i className="icon-cog" onClick={this.handleSettingsClick} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,26 @@
import React, { memo } from 'react';
import classnames from 'classnames';
const TabListItem = ({
target,
content,
server,
selected,
connected,
onClick
}) => {
const className = classnames({
'tab-server': !target,
success: !target && connected,
error: !target && !connected,
selected
});
return (
<p className={className} onClick={() => onClick(server, target)}>
<span className="tab-content">{content}</span>
</p>
);
};
export default memo(TabListItem);

View file

@ -0,0 +1,123 @@
import React, { Component } from 'react';
import { isChannel } from 'utils';
import ChatTitle from './ChatTitle';
import Search from './Search';
import MessageBox from './MessageBox';
import MessageInput from './MessageInput';
import UserList from './UserList';
export default class Chat extends Component {
handleCloseClick = () => {
const { tab, part, closePrivateChat, disconnect } = this.props;
if (isChannel(tab)) {
part([tab.name], tab.server);
} else if (tab.name) {
closePrivateChat(tab.server, tab.name);
} else {
disconnect(tab.server);
}
};
handleSearch = phrase => {
const { tab, searchMessages } = this.props;
if (isChannel(tab)) {
searchMessages(tab.server, tab.name, phrase);
}
};
handleNickClick = nick => {
const { tab, openPrivateChat, select } = this.props;
openPrivateChat(tab.server, nick);
select(tab.server, nick);
};
handleTitleChange = title => {
const { setServerName, tab } = this.props;
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,
coloredNicks,
currentInputHistoryEntry,
hasMoreMessages,
messages,
nick,
search,
showUserList,
status,
tab,
title,
users,
addFetchedMessages,
fetchMessages,
inputActions,
runCommand,
sendMessage,
toggleSearch,
toggleUserList
} = this.props;
let chatClass;
if (isChannel(tab)) {
chatClass = 'chat-channel';
} else if (tab.name) {
chatClass = 'chat-private';
} else {
chatClass = 'chat-server';
}
return (
<div className={chatClass}>
<ChatTitle
channel={channel}
status={status}
tab={tab}
title={title}
onCloseClick={this.handleCloseClick}
onTitleChange={this.handleTitleChange}
onToggleSearch={toggleSearch}
onToggleUserList={toggleUserList}
/>
<Search search={search} onSearch={this.handleSearch} />
<MessageBox
coloredNicks={coloredNicks}
hasMoreMessages={hasMoreMessages}
messages={messages}
tab={tab}
onAddMore={addFetchedMessages}
onFetchMore={fetchMessages}
onNickClick={this.handleNickClick}
/>
<MessageInput
currentHistoryEntry={currentInputHistoryEntry}
nick={nick}
tab={tab}
onCommand={runCommand}
onMessage={sendMessage}
onNickChange={this.handleNickChange}
onNickEditDone={this.handleNickEditDone}
{...inputActions}
/>
<UserList
coloredNicks={coloredNicks}
showUserList={showUserList}
users={users}
onNickClick={this.handleNickClick}
/>
</div>
);
}
}

View file

@ -0,0 +1,73 @@
import React, { memo } from 'react';
import Navicon from 'containers/Navicon';
import Editable from 'components/ui/Editable';
import { isValidServerName } from 'state/servers';
import { isChannel, linkify } from 'utils';
const ChatTitle = ({
status,
title,
tab,
channel,
onTitleChange,
onToggleSearch,
onToggleUserList,
onCloseClick
}) => {
let closeTitle;
if (isChannel(tab)) {
closeTitle = 'Leave';
} else if (tab.name) {
closeTitle = 'Close';
} else {
closeTitle = 'Disconnect';
}
let serverError = null;
if (!tab.name && status.error) {
serverError = (
<span className="chat-topic error">
Error:
{status.error}
</span>
);
}
return (
<div>
<div className="chat-title-bar">
<Navicon />
<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">
<span className="chat-topic">
{channel && linkify(channel.topic)}
</span>
{serverError}
</div>
<i className="icon-search" title="Search" onClick={onToggleSearch} />
<i
className="icon-cancel button-leave"
title={closeTitle}
onClick={onCloseClick}
/>
<i className="icon-user button-userlist" onClick={onToggleUserList} />
</div>
<div className="userlist-bar">
<i className="icon-user" />
<span className="chat-usercount">
{channel && channel.users.length}
</span>
</div>
</div>
);
};
export default memo(ChatTitle);

View file

@ -0,0 +1,38 @@
import React, { memo } from 'react';
import classnames from 'classnames';
import stringToRGB from 'utils/color';
const Message = ({ message, coloredNick, style, onNickClick }) => {
const className = classnames('message', {
[`message-${message.type}`]: message.type
});
style = {
...style,
paddingLeft: `${window.messageIndent + 15}px`,
textIndent: `-${window.messageIndent}px`
};
const senderStyle = {};
if (message.from && coloredNick) {
senderStyle.color = stringToRGB(message.from);
}
return (
<p className={className} style={style}>
<span className="message-time">{message.time} </span>
{message.from && (
<span
className="message-sender"
style={senderStyle}
onClick={() => onNickClick(message.from)}
>
{message.from}
</span>
)}
<span> {message.content}</span>
</p>
);
};
export default memo(Message);

View file

@ -0,0 +1,250 @@
import React, { PureComponent, createRef } from 'react';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import debounce from 'lodash/debounce';
import { getScrollPos, saveScrollPos } from 'utils/scrollPosition';
import Message from './Message';
const fetchThreshold = 600;
// The amount of time in ms that needs to pass without any
// scroll events happening before adding messages to the top,
// this is done to prevent the scroll from jumping all over the place
const scrollbackDebounce = 100;
export default class MessageBox extends PureComponent {
list = createRef();
outer = createRef();
addMore = debounce(() => {
const { tab, onAddMore } = this.props;
this.ready = true;
onAddMore(tab.server, tab.name);
}, scrollbackDebounce);
constructor(props) {
super(props);
this.loadScrollPos();
}
componentDidMount() {
const scrollToBottom = this.bottom;
window.requestAnimationFrame(() => {
const { messages } = this.props;
if (scrollToBottom && messages.length > 0) {
this.list.current.scrollToItem(messages.length + 1);
}
});
}
componentDidUpdate(prevProps) {
if (prevProps.tab !== this.props.tab) {
this.loadScrollPos(true);
}
if (this.nextScrollTop > 0) {
this.list.current.scrollTo(this.nextScrollTop);
this.nextScrollTop = 0;
} else if (this.bottom) {
this.list.current.scrollToItem(this.props.messages.length + 1);
}
}
componentWillUnmount() {
this.saveScrollPos();
}
getSnapshotBeforeUpdate(prevProps) {
if (prevProps.messages !== this.props.messages) {
this.list.current.resetAfterIndex(0);
}
if (prevProps.tab !== this.props.tab) {
this.saveScrollPos();
this.bottom = false;
}
if (prevProps.messages[0] !== this.props.messages[0]) {
const { messages, hasMoreMessages } = this.props;
if (prevProps.tab === this.props.tab) {
const addedMessages = messages.length - prevProps.messages.length;
let addedHeight = 0;
for (let i = 0; i < addedMessages; i++) {
addedHeight += messages[i].height;
}
this.nextScrollTop = addedHeight + this.outer.current.scrollTop;
if (!hasMoreMessages) {
this.nextScrollTop -= 93;
}
}
this.loading = false;
this.ready = false;
}
return null;
}
getRowHeight = index => {
const { messages, hasMoreMessages } = this.props;
if (index === 0) {
if (hasMoreMessages) {
return 100;
}
return 7;
} else if (index === messages.length + 1) {
return 7;
}
return messages[index - 1].height;
};
getItemKey = index => {
const { messages } = this.props;
if (index === 0) {
return 'top';
} else if (index === messages.length + 1) {
return 'bottom';
}
return messages[index - 1].id;
};
updateScrollKey = () => {
const { tab } = this.props;
this.scrollKey = `msg:${tab.server}:${tab.name}`;
return this.scrollKey;
};
loadScrollPos = scroll => {
const pos = getScrollPos(this.updateScrollKey());
if (pos >= 0) {
this.bottom = false;
if (scroll) {
this.list.current.scrollTo(pos);
} else {
this.initialScrollTop = pos;
}
} else {
this.bottom = true;
if (scroll) {
this.list.current.scrollToItem(this.props.messages.length + 1);
}
}
};
saveScrollPos = () => {
if (this.bottom) {
saveScrollPos(this.scrollKey, -1);
} else {
saveScrollPos(this.scrollKey, this.outer.current.scrollTop);
}
};
fetchMore = () => {
this.loading = true;
this.props.onFetchMore();
};
handleScroll = ({ scrollOffset, scrollDirection }) => {
if (
!this.loading &&
this.props.hasMoreMessages &&
scrollOffset <= fetchThreshold &&
scrollDirection === 'backward'
) {
this.fetchMore();
}
if (this.loading && !this.ready) {
if (this.mouseDown) {
this.ready = true;
this.shouldAdd = true;
} else {
this.addMore();
}
}
const { clientHeight, scrollHeight } = this.outer.current;
this.bottom = scrollOffset + clientHeight >= scrollHeight - 20;
};
handleMouseDown = () => {
this.mouseDown = true;
};
handleMouseUp = () => {
this.mouseDown = false;
if (this.shouldAdd) {
const { tab, onAddMore } = this.props;
this.shouldAdd = false;
onAddMore(tab.server, tab.name);
}
};
renderMessage = ({ index, style }) => {
const { messages } = this.props;
if (index === 0) {
if (this.props.hasMoreMessages) {
return (
<div className="messagebox-top-indicator" style={style}>
Loading messages...
</div>
);
}
return null;
} else if (index === messages.length + 1) {
return null;
}
const { coloredNicks, onNickClick } = this.props;
const message = messages[index - 1];
return (
<Message
message={message}
coloredNick={coloredNicks}
style={style}
onNickClick={onNickClick}
/>
);
};
render() {
return (
<div
className="messagebox"
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
>
<AutoSizer>
{({ width, height }) => (
<List
ref={this.list}
outerRef={this.outer}
width={width}
height={height}
itemCount={this.props.messages.length + 2}
itemKey={this.getItemKey}
itemSize={this.getRowHeight}
estimatedItemSize={32}
initialScrollOffset={this.initialScrollTop}
onScroll={this.handleScroll}
className="messagebox-window"
>
{this.renderMessage}
</List>
)}
</AutoSizer>
</div>
);
}
}

View file

@ -0,0 +1,68 @@
import React, { memo, useState } from 'react';
import classnames from 'classnames';
import Editable from 'components/ui/Editable';
import { isValidNick } from 'utils';
const MessageInput = ({
nick,
currentHistoryEntry,
onNickChange,
onNickEditDone,
tab,
onCommand,
onMessage,
add,
reset,
increment,
decrement
}) => {
const [value, setValue] = useState('');
const handleKey = e => {
if (e.key === 'Enter' && e.target.value) {
if (e.target.value[0] === '/') {
onCommand(e.target.value, tab.name, tab.server);
} else if (tab.name) {
onMessage(e.target.value, tab.name, tab.server);
}
add(e.target.value);
reset();
setValue('');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
increment();
} else if (e.key === 'ArrowDown') {
decrement();
} else if (currentHistoryEntry) {
setValue(e.target.value);
reset();
}
};
const handleChange = e => setValue(e.target.value);
return (
<div className="message-input-wrap">
<Editable
className={classnames('message-input-nick', {
invalid: !isValidNick(nick)
})}
value={nick}
onBlur={onNickEditDone}
onChange={onNickChange}
>
<span className="message-input-nick">{nick}</span>
</Editable>
<input
className="message-input"
type="text"
value={currentHistoryEntry || value}
onKeyDown={handleKey}
onChange={handleChange}
/>
</div>
);
};
export default memo(MessageInput);

View file

@ -0,0 +1,41 @@
import React, { memo, useRef, useEffect } from 'react';
import SearchResult from './SearchResult';
const Search = ({ search, onSearch }) => {
const inputEl = useRef();
useEffect(
() => {
if (search.show) {
inputEl.current.focus();
}
},
[search.show]
);
const style = {
display: search.show ? 'block' : 'none'
};
let i = 0;
const results = search.results.map(result => (
<SearchResult key={i++} result={result} />
));
return (
<div className="search" style={style}>
<div className="search-input-wrap">
<i className="icon-search" />
<input
ref={inputEl}
className="search-input"
type="text"
onChange={e => onSearch(e.target.value)}
/>
</div>
<div className="search-results">{results}</div>
</div>
);
};
export default memo(Search);

View file

@ -0,0 +1,24 @@
import React, { memo } from 'react';
import { timestamp, linkify } from 'utils';
const SearchResult = ({ result }) => {
const style = {
paddingLeft: `${window.messageIndent}px`,
textIndent: `-${window.messageIndent}px`
};
return (
<p className="search-result" style={style}>
<span className="message-time">
{timestamp(new Date(result.time * 1000))}
</span>
<span>
{' '}
<span className="message-sender">{result.from}</span>
</span>
<span> {linkify(result.content)}</span>
</p>
);
};
export default memo(SearchResult);

View file

@ -0,0 +1,92 @@
import React, { PureComponent, createRef } from 'react';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import classnames from 'classnames';
import UserListItem from './UserListItem';
export default class UserList extends PureComponent {
list = createRef();
getSnapshotBeforeUpdate(prevProps) {
if (this.list.current) {
const { users } = this.props;
if (prevProps.users.length !== users.length) {
this.list.current.resetAfterIndex(
Math.min(prevProps.users.length, users.length) + 1
);
} else {
this.list.current.forceUpdate();
}
}
return null;
}
getItemHeight = index => {
const { users } = this.props;
if (index === 0) {
return 12;
} else if (index === users.length + 1) {
return 10;
}
return 24;
};
getItemKey = index => {
const { users } = this.props;
if (index === 0) {
return 'top';
} else if (index === users.length + 1) {
return 'bottom';
}
return index;
};
renderUser = ({ index, style }) => {
const { users, coloredNicks, onNickClick } = this.props;
if (index === 0 || index === users.length + 1) {
return null;
}
return (
<UserListItem
user={users[index - 1]}
coloredNick={coloredNicks}
style={style}
onClick={onNickClick}
/>
);
};
render() {
const { users, showUserList } = this.props;
const className = classnames('userlist', {
'off-canvas': showUserList
});
return (
<div className={className}>
<AutoSizer disableWidth>
{({ height }) => (
<List
ref={this.list}
width={200}
height={height}
itemCount={users.length + 2}
itemKey={this.getItemKey}
itemSize={this.getItemHeight}
estimatedItemSize={24}
>
{this.renderUser}
</List>
)}
</AutoSizer>
</div>
);
}
}

View file

@ -0,0 +1,19 @@
import React, { memo } from 'react';
import stringToRGB from 'utils/color';
const UserListItem = ({ user, coloredNick, style, onClick }) => {
if (coloredNick) {
style = {
...style,
color: stringToRGB(user.nick)
};
}
return (
<p style={style} onClick={() => onClick(user.nick)}>
{user.renderName}
</p>
);
};
export default memo(UserListItem);

View file

@ -0,0 +1,3 @@
import Chat from './Chat';
export default Chat;

View file

@ -0,0 +1,190 @@
import React, { Component } from 'react';
import { createSelector } from 'reselect';
import { Form, withFormik } from 'formik';
import Navicon from 'containers/Navicon';
import Button from 'components/ui/Button';
import Checkbox from 'components/ui/formik/Checkbox';
import TextInput from 'components/ui/TextInput';
import Error from 'components/ui/formik/Error';
import { isValidNick, isValidChannel, isValidUsername, isInt } from 'utils';
const getSortedDefaultChannels = createSelector(
defaults => defaults.channels,
channels => channels.split(',').sort()
);
class Connect extends Component {
state = {
showOptionals: false
};
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);
}
};
handleShowClick = () => {
this.setState(prevState => ({ showOptionals: !prevState.showOptionals }));
};
renderOptionals = () => {
const { hexIP } = this.props;
return (
<div>
{!hexIP && <TextInput name="username" />}
<TextInput name="password" type="password" />
<TextInput name="realname" />
</div>
);
};
render() {
const { defaults, values } = this.props;
const { readOnly, showDetails } = defaults;
let form;
if (readOnly) {
form = (
<Form className="connect-form">
<h1>Connect</h1>
{showDetails && (
<div className="connect-details">
<h2>
{values.host}:{values.port}
</h2>
{getSortedDefaultChannels(values).map(channel => (
<p>{channel}</p>
))}
</div>
)}
<TextInput name="nick" />
<Button type="submit">Connect</Button>
</Form>
);
} else {
form = (
<Form className="connect-form">
<h1>Connect</h1>
<TextInput name="name" autoCapitalize="words" />
<div className="connect-form-address">
<TextInput name="host" noError />
<TextInput name="port" type="number" noError />
<Checkbox
name="tls"
label="SSL"
topLabel
onChange={this.handleSSLChange}
/>
</div>
<Error name="host" />
<Error name="port" />
<TextInput name="nick" />
<TextInput name="channels" />
{this.state.showOptionals && this.renderOptionals()}
<i className="icon-ellipsis" onClick={this.handleShowClick} />
<Button type="submit">Connect</Button>
</Form>
);
}
return (
<div className="connect">
<Navicon />
{form}
</div>
);
}
}
export default withFormik({
enableReinitialize: true,
mapPropsToValues: ({ defaults }) => {
let port = 6667;
if (defaults.port) {
({ port } = defaults);
} else if (defaults.ssl) {
port = 6697;
}
return {
name: defaults.name,
host: defaults.host,
port,
nick: '',
channels: defaults.channels.join(','),
username: '',
password: defaults.password ? ' ' : '',
realname: '',
tls: 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.tls ? 6697 : 6667;
} else if (!isInt(values.port, 1, 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;
values.port = `${values.port}`;
connect(values);
select(values.host);
if (channels.length > 0) {
join(channels, values.host);
}
}
})(Connect);

View file

@ -0,0 +1,79 @@
import React, { useCallback } from 'react';
import Navicon from 'containers/Navicon';
import Button from 'components/ui/Button';
import Checkbox from 'components/ui/Checkbox';
import FileInput from 'components/ui/FileInput';
const Settings = ({
settings,
installable,
setSetting,
onCertChange,
onKeyChange,
onInstall,
uploadCert
}) => {
const status = settings.uploadingCert ? 'Uploading...' : 'Upload';
const error = settings.certError;
const handleInstallClick = useCallback(
async () => {
installable.prompt();
await installable.userChoice;
onInstall();
},
[installable]
);
return (
<div className="settings-container">
<div className="settings">
<Navicon />
<h1>Settings</h1>
{installable && (
<Button className="button-install" onClick={handleInstallClick}>
<h2>Install</h2>
</Button>
)}
<div className="settings-section">
<h2>Visuals</h2>
<Checkbox
name="coloredNicks"
label="Colored nicks"
checked={settings.coloredNicks}
onChange={e => setSetting('coloredNicks', e.target.checked)}
/>
</div>
<div className="settings-section">
<h2>Client Certificate</h2>
<div className="settings-cert">
<div className="settings-file">
<p>Certificate</p>
<FileInput
name={settings.certFile || 'Select Certificate'}
onChange={onCertChange}
/>
</div>
<div className="settings-file">
<p>Private Key</p>
<FileInput
name={settings.keyFile || 'Select Key'}
onChange={onKeyChange}
/>
</div>
<Button
type="submit"
className="settings-button"
onClick={uploadCert}
>
{status}
</Button>
{error ? <p className="error">{error}</p> : null}
</div>
</div>
</div>
</div>
);
};
export default Settings;

View file

@ -0,0 +1,9 @@
import React from 'react';
const Button = ({ children, ...props }) => (
<button type="button" {...props}>
{children}
</button>
);
export default Button;

View file

@ -0,0 +1,18 @@
import React from 'react';
import classnames from 'classnames';
const Checkbox = ({ name, label, topLabel, ...props }) => (
<label
className={classnames('checkbox', {
'top-label': topLabel
})}
htmlFor={name}
>
{topLabel && label}
<input type="checkbox" id={name} name={name} {...props} />
<span />
{!topLabel && label}
</label>
);
export default Checkbox;

View file

@ -0,0 +1,107 @@
import React, { PureComponent, createRef } from 'react';
import { stringWidth } from 'utils';
export default class Editable extends PureComponent {
static defaultProps = {
editable: true
};
inputEl = createRef();
state = {
editing: false
};
componentDidUpdate(prevProps, prevState) {
if (!prevState.editing && this.state.editing) {
// eslint-disable-next-line react/no-did-update-set-state
this.updateInputWidth(this.props.value);
this.inputEl.current.focus();
}
}
getSnapshotBeforeUpdate(prevProps) {
if (this.state.editing && prevProps.value !== this.props.value) {
this.updateInputWidth(this.props.value);
}
}
updateInputWidth = value => {
if (this.inputEl.current) {
const style = window.getComputedStyle(this.inputEl.current);
const padding = parseInt(style.paddingRight, 10);
// Make sure the width is at least 1px so the caret always shows
const width =
stringWidth(value, `${style.fontSize} ${style.fontFamily}`) || 1;
this.setState({
width: width + padding * 2,
indent: padding
});
}
};
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 });
};
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);
}
};
handleFocus = e => {
const val = e.target.value;
e.target.value = '';
e.target.value = val;
};
render() {
const { children, className, value } = this.props;
const style = {
width: this.state.width,
textIndent: this.state.indent,
paddingLeft: 0
};
return this.state.editing ? (
<input
ref={this.inputEl}
className={className}
type="text"
value={value}
onBlur={this.handleBlur}
onChange={this.handleChange}
onKeyDown={this.handleKey}
onFocus={this.handleFocus}
style={style}
spellCheck={false}
/>
) : (
<div onClick={this.startEditing}>{children}</div>
);
}
}

View file

@ -0,0 +1,48 @@
import React, { PureComponent } from 'react';
import Button from 'components/ui/Button';
export default class FileInput extends PureComponent {
static defaultProps = {
type: 'text'
};
constructor(props) {
super(props);
this.input = window.document.createElement('input');
this.input.setAttribute('type', 'file');
this.input.addEventListener('change', e => {
const file = e.target.files[0];
const reader = new FileReader();
const { onChange, type } = this.props;
reader.onload = () => {
onChange(file.name, reader.result);
};
switch (type) {
case 'binary':
reader.readAsArrayBuffer(file);
break;
case 'text':
reader.readAsText(file);
break;
default:
reader.readAsText(file);
}
});
}
handleClick = () => this.input.click();
render() {
return (
<Button className="input-file" onClick={this.handleClick}>
{this.props.name}
</Button>
);
}
}

View file

@ -0,0 +1,7 @@
import React from 'react';
const Navicon = ({ onClick }) => (
<i className="icon-menu navicon" onClick={onClick} />
);
export default Navicon;

View file

@ -0,0 +1,87 @@
import React, { PureComponent } from 'react';
import { FastField } from 'formik';
import classnames from 'classnames';
import capitalize from 'lodash/capitalize';
import Error from 'components/ui/formik/Error';
export default class TextInput extends PureComponent {
constructor(props) {
super(props);
this.input = React.createRef();
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
handleResize = () => {
if (this.scroll) {
this.scroll = false;
this.scrollIntoView();
}
};
handleFocus = () => {
this.scroll = true;
setTimeout(() => {
this.scroll = false;
}, 2000);
};
scrollIntoView = () => {
if (this.input.current.scrollIntoViewIfNeeded) {
this.input.current.scrollIntoViewIfNeeded();
} else {
this.input.current.scrollIntoView();
}
};
render() {
const { name, label = capitalize(name), noError, ...props } = this.props;
return (
<FastField
name={name}
render={({ field, form }) => {
return (
<>
<div className="textinput">
<input
className={field.value && 'value'}
type="text"
name={name}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck="false"
ref={this.input}
onFocus={this.handleFocus}
{...field}
{...props}
/>
<span
className={classnames('textinput-1', {
value: field.value,
error: form.touched[name] && form.errors[name]
})}
>
{label}
</span>
<span
className={classnames('textinput-2', {
value: field.value,
error: form.touched[name] && form.errors[name]
})}
>
{label}
</span>
</div>
{!noError && <Error name={name} />}
</>
);
}}
/>
);
}
}

View file

@ -0,0 +1,27 @@
import React, { memo } from 'react';
import { FastField } from 'formik';
import Checkbox from 'components/ui/Checkbox';
const FormikCheckbox = ({ name, onChange, ...props }) => (
<FastField
name={name}
render={({ field, form }) => {
return (
<Checkbox
name={name}
checked={field.value}
onChange={e => {
form.setFieldTouched(name, true);
field.onChange(e);
if (onChange) {
onChange(e);
}
}}
{...props}
/>
);
}}
/>
);
export default memo(FormikCheckbox);

View file

@ -0,0 +1,8 @@
import React from 'react';
import { ErrorMessage } from 'formik';
const Error = props => (
<ErrorMessage component="div" className="form-error" {...props} />
);
export default Error;