Compare commits

..

No commits in common. "d3d83236467e9ea3fbeb7f9a4ca66d773e38f4d2" and "b9b69281112f2793438862a92277b26def221a53" have entirely different histories.

2039 changed files with 130638 additions and 368545 deletions

View File

@ -1,5 +1,3 @@
dist .git
dispatch
client/dist client/dist
client/node_modules client/node_modules
client/yarn-error.log

5
.gitignore vendored
View File

@ -1,5 +1,6 @@
dist build
dispatch release
client/dist client/dist
client/node_modules client/node_modules
client/yarn-error.log client/yarn-error.log
ca-certificates.crt

View File

@ -1,45 +0,0 @@
builds:
- ldflags:
- -s -w -X github.com/khlieng/dispatch/version.Tag=v{{.Version}} -X github.com/khlieng/dispatch/version.Commit={{.ShortCommit}} -X github.com/khlieng/dispatch/version.Date={{.Date}}
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- 6
- 7
archives:
- files:
- none*
format_overrides:
- goos: windows
format: zip
replacements:
amd64: x64
darwin: mac
checksum:
name_template: "checksums.txt"
changelog:
filters:
exclude:
- "(?i)^update.*dep"
- Merge pull request
- Merge branch
release:
name_template: "{{.Version}}"

View File

@ -1,7 +1,8 @@
language: go language: go
go: go:
- 1.x - 1.8.3
- 1.9
- tip - tip
os: os:
@ -13,25 +14,17 @@ matrix:
- go: tip - go: tip
install: install:
- GO111MODULE=off go get github.com/jteeuwen/go-bindata/... - go get github.com/jteeuwen/go-bindata/...
- cd client - cd client
- nvm install --lts - nvm install 7.10.0
- nvm use --lts - nvm use 7.10.0
- npm install -g yarn - npm install -g yarn
- yarn global add gulp-cli - npm install -g gulp
- yarn - yarn
script: script:
- yarn test:verbose - npm run test:verbose
- gulp build - gulp build
- cd .. - cd ..
- go vet ./... - go vet $(go list ./... | grep -v '/vendor/')
- go test -v -race ./... - go test -v -race $(go list ./... | grep -v '/vendor/')
deploy:
- provider: script
skip_cleanup: true
script: git checkout -- . && curl -sL https://git.io/goreleaser | bash
on:
tags: true
condition: $TRAVIS_OS_NAME = linux && $TRAVIS_GO_VERSION = 1.*

View File

@ -1,3 +0,0 @@
{
"javascript.preferences.importModuleSpecifier": "non-relative"
}

View File

@ -1,22 +1,7 @@
# Build FROM scratch
FROM golang:alpine AS build
RUN apk add --update git make build-base && \ ADD build/dispatch /
rm -rf /var/cache/apk/* ADD ca-certificates.crt /etc/ssl/certs/
WORKDIR /go/src/github.com/khlieng/dispatch
COPY . /go/src/github.com/khlieng/dispatch
RUN chmod +x install.sh && ./install.sh
# Runtime
FROM alpine
RUN apk add --update ca-certificates && \
rm -rf /var/cache/apk/*
COPY --from=build /go/bin/dispatch /dispatch
EXPOSE 80/tcp
VOLUME ["/data"] VOLUME ["/data"]

View File

@ -1,126 +1,91 @@
# dispatch [![Build Status](https://travis-ci.com/khlieng/dispatch.svg?branch=master)](https://travis-ci.com/khlieng/dispatch) # dispatch [![Build Status](https://travis-ci.org/khlieng/dispatch.svg?branch=master)](https://travis-ci.org/khlieng/dispatch)
#### [Try it!](https://dispatch.khlieng.com) #### [Try it!](https://dispatch.khlieng.com)
![Dispatch](https://khlieng.com/dispatch.png?1) ![Dispatch](https://khlieng.com/dispatch.png)
### Features ### Features
* Searchable history
- Searchable history * Persistent connections
- Persistent connections * Multiple servers and users
- Multiple servers and users * Automatic HTTPS through Let's Encrypt
- Automatic HTTPS through Let's Encrypt * Client certificates
- Single binary with no dependencies
- DCC downloads
- SASL
- Client certificates
## Usage ## Usage
There is a few different ways of getting it: There is a few different ways of getting it:
### 1. Binary ### 1. Binary
- **[Windows (x64)](https://github.com/khlieng/dispatch/releases/download/v0.4/dispatch_windows_amd64.zip)**
- **[Windows (x64)](https://release.khlieng.com/khlieng/dispatch/windows_x64)** - **[OS X (x64)](https://github.com/khlieng/dispatch/releases/download/v0.4/dispatch_darwin_amd64.zip)**
- **[macOS (x64)](https://release.khlieng.com/khlieng/dispatch/mac_x64)** - **[Linux (x64)](https://github.com/khlieng/dispatch/releases/download/v0.4/dispatch_linux_amd64.tar.gz)**
- **[Linux (x64)](https://release.khlieng.com/khlieng/dispatch/linux_x64)**
- [Other versions](https://github.com/khlieng/dispatch/releases) - [Other versions](https://github.com/khlieng/dispatch/releases)
### 2. Go ### 2. Go
This requires a [Go environment](http://golang.org/doc/install), version 1.8 or greater.
This requires a [Go environment](http://golang.org/doc/install), version 1.11 or greater.
Fetch, compile and run dispatch: Fetch, compile and run dispatch:
```bash ```bash
go get github.com/khlieng/dispatch go get github.com/khlieng/dispatch
dispatch dispatch
``` ```
To get some help run: To get some help run:
```bash ```bash
dispatch help dispatch help
``` ```
### 3. Docker ### 3. Docker
```bash ```bash
docker run \ docker run -p <http port>:80 -p <https port>:443 -v <path>:/data khlieng/dispatch
-p <http port>:80 \
-p <https port>:443 \
-v <path>:/data \
--restart unless-stopped \
-d khlieng/dispatch
``` ```
## Build ## Build
### Server ### Server
```bash ```bash
cd $GOPATH/src/github.com/khlieng/dispatch
go install go install
``` ```
### Client ### Client
This requires [Node.js](https://nodejs.org).
This requires [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com).
Fetch the dependencies: Fetch the dependencies:
```bash ```bash
GO111MODULE=off go get github.com/jteeuwen/go-bindata/... npm install -g gulp
yarn global add gulp-cli go get github.com/jteeuwen/go-bindata/...
cd client cd $GOPATH/src/github.com/khlieng/dispatch/client
yarn npm install
``` ```
Run the build: Run the build:
```bash ```bash
gulp build gulp build
``` ```
The server needs to be rebuilt to embed new client builds. The server needs to be rebuilt after this.
For development with hot reloading start the frontend:
For development with hot reloading enabled run:
```bash ```bash
gulp gulp
```
And then the backend in a separate terminal:
```bash
dispatch --dev dispatch --dev
``` ```
## IRC Channel
#dispatch @ irc.libera.chat
## Libraries ## Libraries
The libraries this project is built with. The libraries this project is built with.
### Server ### Server
- [Bolt](https://github.com/boltdb/bolt)
- [Bolt](https://github.com/etcd-io/bbolt)
- [Bleve](https://github.com/blevesearch/bleve) - [Bleve](https://github.com/blevesearch/bleve)
- [Cobra](https://github.com/spf13/cobra) - [Cobra](https://github.com/spf13/cobra)
- [Viper](https://github.com/spf13/viper) - [Viper](https://github.com/spf13/viper)
- [CertMagic](https://github.com/mholt/certmagic) - [Lego](https://github.com/xenolf/lego)
### Client ### Client
- [React](https://github.com/facebook/react) - [React](https://github.com/facebook/react)
- [Redux](https://github.com/reactjs/redux) - [Redux](https://github.com/reactjs/redux)
- [Immer](https://github.com/mweststrate/immer) - [Immutable](https://github.com/facebook/immutable-js)
- [react-window](https://github.com/bvaughn/react-window) - [React Virtualized](https://github.com/bvaughn/react-virtualized)
- [Lodash](https://github.com/lodash/lodash) - [Lodash](https://github.com/lodash/lodash)
## Big Thanks
Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs][homepage]
[homepage]: https://saucelabs.com

File diff suppressed because one or more lines are too long

35
client/.babelrc Normal file
View File

@ -0,0 +1,35 @@
{
"presets": [
["es2015", { "modules": false, "loose": true }],
"react",
"stage-0"
],
"plugins": [
["module-resolver", {
"root": ["./src/js"],
"alias": {
"components": "./components",
"containers": "./containers",
"state": "./state",
"util": "./util"
}
}]
],
"env": {
"development": {
"plugins": ["react-hot-loader/babel"]
},
"test": {
"plugins": [
"rewire",
"transform-es2015-modules-commonjs"
]
},
"production": {
"plugins": [
"transform-react-inline-elements",
"transform-react-constant-elements"
]
}
}
}

View File

@ -1,35 +1,29 @@
{ {
"extends": ["airbnb", "prettier", "prettier/react"], "extends": "airbnb",
"parser": "babel-eslint", "parser": "babel-eslint",
"env": { "env": {
"browser": true "browser": true
}, },
"plugins": ["babel"],
"rules": { "rules": {
"arrow-parens": 0,
"comma-dangle": [2, "never"],
"consistent-return": 0, "consistent-return": 0,
"jsx-a11y/click-events-have-key-events": 0,
"jsx-a11y/no-noninteractive-element-interactions": 0,
"jsx-a11y/no-static-element-interactions": 0, "jsx-a11y/no-static-element-interactions": 0,
"no-console": 1, "new-cap": [2, { "capIsNewExceptions": ["Map", "List", "Record", "Set"] }],
"no-console": 0,
"no-param-reassign": 0, "no-param-reassign": 0,
"no-plusplus": 0, "no-plusplus": 0,
"no-restricted-globals": 1,
"no-underscore-dangle": 1,
"react/destructuring-assignment": 0,
"react/jsx-filename-extension": 0, "react/jsx-filename-extension": 0,
"react/jsx-props-no-spreading": 0, "react/no-array-index-key": 0,
"react/prop-types": 0, "react/prop-types": 0,
"react/state-in-constructor": 0, "react/prefer-stateless-function": 0
"react/static-property-placement": 0, },
"globals": {
"no-unused-expressions": 0, "DEV": true
"babel/no-unused-expressions": 2
}, },
"settings": { "settings": {
"import/resolver": { "import/resolver": {
"webpack": { "babel-module": {}
"config": "webpack.config.prod.js"
}
} }
} }
} }

View File

@ -1,5 +0,0 @@
{
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid"
}

View File

@ -1,30 +0,0 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
modules: false,
loose: true
}
],
'@babel/preset-react'
],
plugins: [
['@babel/plugin-proposal-class-properties', { loose: true }],
'@babel/plugin-proposal-export-default-from',
'@babel/plugin-proposal-export-namespace-from',
'@babel/plugin-syntax-dynamic-import',
'react-hot-loader/babel'
],
env: {
test: {
plugins: ['@babel/plugin-transform-modules-commonjs']
},
production: {
plugins: [
'@babel/plugin-transform-react-inline-elements',
'@babel/plugin-transform-react-constant-elements'
]
}
}
};

View File

@ -1,35 +0,0 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
src: local('Montserrat-Regular'),
url(/font/Montserrat-Regular.woff2) format('woff2'),
url(/font/Montserrat-Regular.woff) format('woff');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
src: local('Montserrat-Bold'),
url(/font/Montserrat-Bold.woff2) format('woff2'),
url(/font/Montserrat-Bold.woff) format('woff');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
src: local('Roboto Mono'), local('RobotoMono-Regular'),
url(/font/RobotoMono-Regular.woff2) format('woff2'),
url(/font/RobotoMono-Regular.woff) format('woff');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 700;
src: local('Roboto Mono Bold'), local('RobotoMono-Bold'),
url(/font/RobotoMono-Bold.woff2) format('woff2'),
url(/font/RobotoMono-Bold.woff) format('woff');
}

File diff suppressed because it is too large Load Diff

View File

@ -1,116 +1,118 @@
var path = require('path');
var exec = require('child_process').exec; var exec = require('child_process').exec;
var url = require('url');
var gulp = require('gulp'); var gulp = require('gulp');
var gutil = require('gulp-util'); var gutil = require('gulp-util');
var nano = require('gulp-cssnano');
var autoprefixer = require('gulp-autoprefixer');
var concat = require('gulp-concat');
var cache = require('gulp-cached');
var express = require('express'); var express = require('express');
var proxy = require('express-http-proxy'); var proxy = require('express-http-proxy');
var webpack = require('webpack'); var webpack = require('webpack');
var through = require('through2'); var through = require('through2');
var br = require('brotli'); var br = require('brotli');
var del = require('del');
function brotli(opts) { function brotli(opts) {
return through.obj(function (file, enc, callback) { return through.obj(function(file, enc, callback) {
if (file.isNull()) { if (file.isNull()) {
return callback(null, file); return callback(null, file);
} }
if (file.isStream()) { if (file.isStream()) {
this.emit( this.emit('error', new gutil.PluginError('brotli', 'Streams not supported'));
'error',
new gutil.PluginError('brotli', 'Streams not supported')
);
} else if (file.isBuffer()) { } else if (file.isBuffer()) {
file.path += '.br'; file.path += '.br';
file.contents = Buffer.from(br.compress(file.contents, opts).buffer); file.contents = new Buffer(br.compress(file.contents, opts).buffer);
return callback(null, file); return callback(null, file);
} }
}); });
} }
function clean() { gulp.task('css', function() {
return del(['dist']); return gulp.src(['src/css/fonts.css', 'src/css/fontello.css', 'src/css/style.css'])
} .pipe(concat('bundle.css'))
.pipe(autoprefixer())
.pipe(nano())
.pipe(gulp.dest('dist'));
});
function js(cb) { gulp.task('js', function(cb) {
var config = require('./webpack.config.prod.js'); var config = require('./webpack.config.prod.js');
var compiler = webpack(config); var compiler = webpack(config);
process.env['NODE_ENV'] = 'production'; process.env['NODE_ENV'] = 'production';
compiler.run(function (err, stats) { compiler.run(function(err, stats) {
if (err) { if (err) throw new gutil.PluginError('webpack', err);
throw new gutil.PluginError('webpack', err);
}
gutil.log( gutil.log('[webpack]', stats.toString({
'[webpack]',
stats.toString({
colors: true colors: true
}) }));
);
if (stats.hasErrors()) { if (stats.hasErrors()) process.exit(1);
process.exit(1);
}
cb(); cb();
}); });
} });
function config() { gulp.task('fonts', function() {
return gulp.src('../config.default.toml').pipe(gulp.dest('dist')); return gulp.src('src/font/*')
} .pipe(gulp.dest('dist/font'));
});
function public() { gulp.task('fonts:woff', function() {
return gulp.src('public/**/*').pipe(gulp.dest('dist')); return gulp.src('src/font/*(*.woff|*.woff2)')
} .pipe(gulp.dest('dist/br/font'));
});
gulp.task('config', function() {
return gulp.src('../config.default.toml')
.pipe(gulp.dest('dist/br'));
});
function compress() { function compress() {
return gulp return gulp.src(['dist/**/!(*.br|*.woff|*.woff2)', '!dist/{br,br/**}'])
.src(['dist/**/*(*.js|*.css|*.json)', '!dist/**/*(*.dev.js)'])
.pipe(brotli({ quality: 11 })) .pipe(brotli({ quality: 11 }))
.pipe(gulp.dest('dist')); .pipe(gulp.dest('dist/br'));
} }
function cleanup() { gulp.task('compress', ['css', 'js', 'fonts'], compress);
return del(['dist/**/*(*.js|*.css|*.json|*.map)']); gulp.task('compress:dev', ['css', 'fonts'], compress);
}
function bindata(cb) { gulp.task('bindata', ['compress', 'config'], function(cb) {
exec( exec('go-bindata -nomemcopy -nocompress -pkg assets -o ../assets/bindata.go -prefix "dist/br" dist/br/...', cb);
'go-bindata -nomemcopy -nocompress -pkg assets -o ../assets/bindata.go -prefix "dist" dist/...', });
cb
); gulp.task('bindata:dev', ['compress:dev', 'config'], function(cb) {
} exec('go-bindata -debug -pkg assets -o ../assets/bindata.go -prefix "dist/br" dist/br/...', cb);
});
gulp.task('dev', ['css', 'fonts', 'fonts:woff', 'config', 'compress:dev', 'bindata:dev'], function() {
gulp.watch('src/css/*.css', ['css']);
function serve() {
var config = require('./webpack.config.dev.js'); var config = require('./webpack.config.dev.js');
var compiler = webpack(config); var compiler = webpack(config);
var app = express(); var app = express();
app.use( app.use(require('webpack-dev-middleware')(compiler, {
require('webpack-dev-middleware')(compiler, {
noInfo: true, noInfo: true,
publicPath: config.output.publicPath, publicPath: config.output.publicPath,
headers: { headers: {
'Access-Control-Allow-Origin': '*' 'Access-Control-Allow-Origin': '*'
} }
}) }));
);
app.use(require('webpack-hot-middleware')(compiler)); app.use(require('webpack-hot-middleware')(compiler));
app.use('/', express.static('dist')); app.use('/', express.static('dist'));
app.use( app.use('*', proxy('localhost:1337', {
'*', proxyReqPathResolver: function(req) {
proxy('localhost:1337', {
proxyReqPathResolver: function (req) {
return req.originalUrl; return req.originalUrl;
} }
}) }));
);
app.listen(3000, function (err) { app.listen(3000, function (err) {
if (err) { if (err) {
@ -120,20 +122,8 @@ function serve() {
console.log('Listening at http://localhost:3000'); console.log('Listening at http://localhost:3000');
}); });
} });
const build = gulp.series( gulp.task('build', ['css', 'js', 'fonts', 'fonts:woff', 'config', 'compress', 'bindata']);
clean,
gulp.parallel(js, config),
compress,
cleanup,
bindata
);
const dev = gulp.series( gulp.task('default', ['dev']);
clean,
gulp.parallel(serve, public, gulp.series(config, bindata))
);
gulp.task('build', build);
gulp.task('default', dev);

View File

@ -1,11 +0,0 @@
/* eslint-disable no-underscore-dangle */
window.__init__ = fetch('/init', {
credentials: 'same-origin'
}).then(res => {
if (res.ok) {
return res.json();
}
throw new Error(res.statusText);
});

View File

@ -1,95 +0,0 @@
import React, { Suspense, lazy, useState, useEffect } from 'react';
import Route from 'containers/Route';
import AppInfo from 'components/AppInfo';
import TabList from 'components/TabList';
import cn from 'classnames';
const Modals = lazy(() => import('components/modals'));
const Chat = lazy(() => import('containers/Chat'));
const Connect = lazy(() =>
import(/* webpackChunkName: "connect" */ 'containers/Connect')
);
const Settings = lazy(() => import('containers/Settings'));
const App = ({
connected,
tab,
channels,
networks,
privateChats,
showTabList,
select,
push,
hideMenu,
openModal,
newVersionAvailable,
hasOpenModals
}) => {
const [renderModals, setRenderModals] = useState(false);
if (!renderModals && hasOpenModals) {
setRenderModals(true);
}
const [starting, setStarting] = useState(true);
useEffect(() => {
setTimeout(() => setStarting(false), 1000);
}, []);
const mainClass = cn('main-container', {
'off-canvas': showTabList
});
const handleClick = () => {
if (showTabList) {
hideMenu();
}
};
return (
<div className="wrap" onClick={handleClick}>
{!starting && !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}
networks={networks}
privateChats={privateChats}
showTabList={showTabList}
select={select}
push={push}
openModal={openModal}
/>
<div className={mainClass}>
<Suspense fallback={<div className="suspense-fallback">...</div>}>
<Route name="chat">
<Chat />
</Route>
<Route name="connect">
<Connect />
</Route>
<Route name="settings">
<Settings />
</Route>
</Suspense>
<Suspense
fallback={<div className="suspense-modal-fallback">...</div>}
>
{renderModals && <Modals />}
</Suspense>
</div>
</div>
</div>
);
};
export default App;

View File

@ -1,28 +0,0 @@
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

@ -1,132 +0,0 @@
import React, { PureComponent } from 'react';
import classnames from 'classnames';
import get from 'lodash/get';
import { FiPlus, FiUser, FiSettings } from 'react-icons/fi';
import Button from 'components/ui/Button';
import TabListItem from 'containers/TabListItem';
import { count } from 'utils';
export default class TabList extends PureComponent {
handleTabClick = (network, target) => this.props.select(network, target);
handleConnectClick = () => this.props.push('/connect');
handleSettingsClick = () => this.props.push('/settings');
render() {
const {
tab,
channels,
networks,
privateChats,
showTabList,
openModal
} = this.props;
const tabs = [];
const className = classnames('tablist', {
'off-canvas': showTabList
});
channels.forEach(network => {
const { address } = network;
const srv = networks[address];
tabs.push(
<TabListItem
key={address}
network={address}
content={srv.name}
selected={tab.network === address && !tab.name}
connected={srv.connected}
onClick={this.handleTabClick}
/>
);
const chanCount = count(network.channels, c => c.joined);
const chanLimit =
get(srv.features, ['CHANLIMIT', '#'], 0) || srv.features.MAXCHANNELS;
let chanLabel;
if (chanLimit > 0) {
chanLabel = (
<span>
<span className="success">{chanCount}</span>/{chanLimit}
</span>
);
} else if (chanCount > 0) {
chanLabel = <span className="success">{chanCount}</span>;
}
tabs.push(
<div
key={`${address}-chans}`}
className="tab-label tab-label-channels"
onClick={() => openModal('channel', address)}
>
<span>CHANNELS {chanLabel}</span>
<Button title="Join Channel">+</Button>
</div>
);
network.channels.forEach(({ name, joined }) =>
tabs.push(
<TabListItem
key={address + name}
network={address}
target={name}
content={name}
joined={joined}
selected={tab.network === address && tab.name === name}
onClick={this.handleTabClick}
/>
)
);
if (privateChats[address] && privateChats[address].length > 0) {
tabs.push(
<div key={`${address}-pm}`} className="tab-label">
<span>
DIRECT MESSAGES{' '}
<span style={{ color: '#6bb758' }}>
{privateChats[address].length}
</span>
</span>
{/* <Button>+</Button> */}
</div>
);
privateChats[address].forEach(nick =>
tabs.push(
<TabListItem
key={address + nick}
network={address}
target={nick}
content={nick}
selected={tab.network === address && tab.name === nick}
onClick={this.handleTabClick}
/>
)
);
}
});
return (
<div className={className}>
<div className="tab-container">{tabs}</div>
<div className="side-buttons">
<Button
icon={FiPlus}
aria-label="Connect"
onClick={this.handleConnectClick}
/>
<Button icon={FiUser} aria-label="User" />
<Button
icon={FiSettings}
aria-label="Settings"
onClick={this.handleSettingsClick}
/>
</div>
</div>
);
}
}

View File

@ -1,46 +0,0 @@
import React from 'react';
import classnames from 'classnames';
function splitContent(content) {
let start = 0;
while (content[start] === '#') {
start++;
}
if (start > 0) {
return [content.slice(0, start), content.slice(start)];
}
return [null, content];
}
const TabListItem = ({
target,
content,
network,
selected,
connected,
joined,
error,
onClick
}) => {
const className = classnames({
'tab-network': !target,
success: !target && connected,
error: (!target && !connected) || (!joined && error),
disabled: !!target && !error && joined === false,
selected
});
const [prefix, name] = splitContent(content);
return (
<p className={className} onClick={() => onClick(network, target)}>
<span className="tab-content">
{prefix && <span className="tab-prefix">{prefix}</span>}
{name}
</span>
</p>
);
};
export default TabListItem;

View File

@ -1,72 +0,0 @@
import React from 'react';
import stringToRGB from 'utils/color';
function nickStyle(nick, color) {
const style = {
fontWeight: 400
};
if (color) {
style.color = stringToRGB(nick);
}
return style;
}
function renderBlock(block, coloredNick, key) {
switch (block.type) {
case 'text':
return block.text;
case 'link':
return (
<a target="_blank" rel="noopener noreferrer" href={block.url} key={key}>
{block.text}
</a>
);
case 'format':
return (
<span style={block.style} key={key}>
{block.text}
</span>
);
case 'nick':
return (
<span
className="message-sender"
style={nickStyle(block.text, coloredNick)}
key={key}
>
{block.text}
</span>
);
case 'events':
return (
<span className="message-events-more" key={key}>
{block.text}
</span>
);
default:
return null;
}
}
const Text = ({ children, coloredNick }) => {
if (!children) {
return null;
}
if (children.length > 1) {
let key = 0;
return children.map(block => renderBlock(block, coloredNick, key++));
}
if (children.length === 1) {
return renderBlock(children[0], coloredNick);
}
return children;
};
export default Text;

View File

@ -1,155 +0,0 @@
import React, { memo, useState, useEffect, useRef } from 'react';
import Modal from 'react-modal';
import { useSelector, useDispatch } from 'react-redux';
import { FiUsers, FiX } from 'react-icons/fi';
import Text from 'components/Text';
import useModal from 'components/modals/useModal';
import Button from 'components/ui/Button';
import { join } from 'state/channels';
import { searchChannels } from 'state/channelSearch';
import { linkify } from 'utils';
import colorify from 'utils/colorify';
const Channel = memo(({ network, name, topic, userCount, joined }) => {
const dispatch = useDispatch();
const handleClick = () => dispatch(join([name], network));
return (
<div className="modal-channel-result">
<div className="modal-channel-result-header">
<h2 className="modal-channel-name" onClick={handleClick}>
{name}
</h2>
<FiUsers />
<span className="modal-channel-users">{userCount}</span>
{joined ? (
<span style={{ color: '#6bb758' }}>Joined</span>
) : (
<Button
className="modal-channel-button-join"
category="normal"
onClick={handleClick}
>
Join
</Button>
)}
</div>
<p className="modal-channel-topic">
<Text>{colorify(linkify(topic))}</Text>
</p>
</div>
);
});
const AddChannel = () => {
const [modal, network, closeModal] = useModal('channel');
const channels = useSelector(state => state.channels);
const search = useSelector(state => state.channelSearch);
const dispatch = useDispatch();
const [q, setQ] = useState('');
const inputEl = useRef();
const resultsEl = useRef();
const prevSearch = useRef('');
useEffect(() => {
if (modal.isOpen) {
dispatch(searchChannels(network, ''));
setTimeout(() => inputEl.current.focus(), 0);
} else {
prevSearch.current = '';
setQ('');
}
}, [modal.isOpen]);
const handleSearch = e => {
let nextQ = e.target.value.trim().toLowerCase();
setQ(nextQ);
if (nextQ !== q) {
resultsEl.current.scrollTop = 0;
while (nextQ.charAt(0) === '#') {
nextQ = nextQ.slice(1);
}
if (nextQ !== prevSearch.current) {
prevSearch.current = nextQ;
dispatch(searchChannels(network, nextQ));
}
}
};
const handleKey = e => {
if (e.key === 'Enter') {
let channel = e.target.value.trim();
if (channel !== '') {
closeModal(false);
if (channel.charAt(0) !== '#') {
channel = `#${channel}`;
}
dispatch(join([channel], network));
}
}
};
const handleLoadMore = () =>
dispatch(searchChannels(network, q, search.results.length));
let hasMore = !search.end;
if (hasMore) {
if (search.results.length < 10) {
hasMore = false;
} else if (
search.results.length > 10 &&
(search.results.length - 10) % 50 !== 0
) {
hasMore = false;
}
}
return (
<Modal {...modal}>
<div className="modal-channel-input-wrap">
<input
ref={inputEl}
type="text"
value={q}
placeholder="Enter channel name"
onKeyDown={handleKey}
onChange={handleSearch}
/>
<Button
icon={FiX}
className="modal-close modal-channel-close"
onClick={closeModal}
/>
</div>
<div ref={resultsEl} className="modal-channel-results">
{search.results.map(channel => (
<Channel
key={`${network} ${channel.name}`}
network={network}
joined={channels[network]?.[channel.name]?.joined}
{...channel}
/>
))}
{hasMore && (
<Button
className="modal-channel-button-more"
onClick={handleLoadMore}
>
Load more
</Button>
)}
</div>
</Modal>
);
};
export default AddChannel;

View File

@ -1,26 +0,0 @@
import React from 'react';
import Modal from 'react-modal';
import useModal from 'components/modals/useModal';
import Button from 'components/ui/Button';
const Confirm = () => {
const [modal, payload, closeModal] = useModal('confirm');
const { question, confirmation, onConfirm } = payload;
const handleConfirm = () => {
closeModal(false);
onConfirm();
};
return (
<Modal {...modal}>
<p>{question}</p>
<Button onClick={handleConfirm}>{confirmation || 'OK'}</Button>
<Button category="normal" onClick={closeModal}>
Cancel
</Button>
</Modal>
);
};
export default Confirm;

View File

@ -1,30 +0,0 @@
import React from 'react';
import Modal from 'react-modal';
import { useSelector } from 'react-redux';
import { FiX } from 'react-icons/fi';
import Text from 'components/Text';
import Button from 'components/ui/Button';
import useModal from 'components/modals/useModal';
import { getSelectedChannel } from 'state/channels';
import { linkify } from 'utils';
import colorify from 'utils/colorify';
const Topic = () => {
const [modal, channel, closeModal] = useModal('topic');
const topic = useSelector(state => getSelectedChannel(state)?.topic);
return (
<Modal {...modal}>
<div className="modal-header">
<h2>Topic in {channel}</h2>
<Button icon={FiX} className="modal-close" onClick={closeModal} />
</div>
<p className="modal-content">
<Text>{colorify(linkify(topic))}</Text>
</p>
</Modal>
);
};
export default Topic;

View File

@ -1,14 +0,0 @@
import React, { memo } from 'react';
import AddChannel from 'components/modals/AddChannel';
import Confirm from 'components/modals/Confirm';
import Topic from 'components/modals/Topic';
const Modals = () => (
<>
<AddChannel />
<Confirm />
<Topic />
</>
);
export default memo(Modals, () => true);

View File

@ -1,46 +0,0 @@
import { useCallback } from 'react';
import Modal from 'react-modal';
import { useSelector, useDispatch } from 'react-redux';
import { closeModal } from 'state/modals';
Modal.setAppElement('#root');
const defaultPayload = {};
export default function useModal(name) {
const isOpen = useSelector(state => state.modals[name]?.isOpen || false);
const payload = useSelector(
state => state.modals[name]?.payload || defaultPayload
);
const dispatch = useDispatch();
const handleRequestClose = useCallback(
(dismissed = true) => {
dispatch(closeModal(name));
if (dismissed && payload.onDismiss) {
payload.onDismiss();
}
},
[payload.onDismiss]
);
const modalProps = {
isOpen,
contentLabel: name,
onRequestClose: handleRequestClose,
className: {
base: `modal modal-${name}`,
afterOpen: 'modal-opening',
beforeClose: 'modal-closing'
},
overlayClassName: {
base: 'modal-overlay',
afterOpen: 'modal-overlay-opening',
beforeClose: 'modal-overlay-closing'
},
closeTimeoutMS: 200
};
return [modalProps, payload, handleRequestClose];
}

View File

@ -1,87 +0,0 @@
import React, { memo } from 'react';
import { FiUsers, FiSearch, FiX } from 'react-icons/fi';
import Navicon from 'components/ui/Navicon';
import Button from 'components/ui/Button';
import Editable from 'components/ui/Editable';
import { isValidNetworkName } from 'state/networks';
import { isChannel } from 'utils';
const ChatTitle = ({
error,
title,
tab,
channel,
openModal,
onTitleChange,
onToggleSearch,
onToggleUserList,
onCloseClick
}) => {
let closeTitle;
if (isChannel(tab)) {
closeTitle = 'Leave';
} else if (tab.name) {
closeTitle = 'Close';
} else {
closeTitle = 'Disconnect';
}
let networkError = null;
if (!tab.name && error) {
networkError = <span className="chat-topic error">Error: {error}</span>;
}
return (
<div>
<div className="chat-title-bar">
<Navicon />
<Editable
className="chat-title"
editable={!tab.name}
value={title}
validate={isValidNetworkName}
onChange={onTitleChange}
>
<span className="chat-title">{title}</span>
</Editable>
<div className="chat-topic-wrap">
{channel?.topic && (
<span
className="chat-topic"
onClick={() => openModal('topic', channel.name)}
>
{channel.topic}
</span>
)}
{networkError}
</div>
{tab.name && (
<Button
icon={FiSearch}
title="Search"
aria-label="Search"
onClick={onToggleSearch}
/>
)}
<Button
icon={FiX}
title={closeTitle}
aria-label={closeTitle}
onClick={onCloseClick}
/>
<Button
icon={FiUsers}
className="button-userlist"
aria-label="Users"
onClick={onToggleUserList}
/>
</div>
<div className="userlist-bar">
<FiUsers />
{channel?.users.length}
</div>
</div>
);
};
export default memo(ChatTitle);

View File

@ -1,50 +0,0 @@
import React, { memo } from 'react';
import classnames from 'classnames';
import Text from 'components/Text';
import stringToRGB from 'utils/color';
const Message = ({ message, coloredNick, onNickClick }) => {
const className = classnames('message', {
[`message-${message.type}`]: message.type
});
if (message.type === 'date') {
return (
<div className={className}>
{message.content}
<hr />
</div>
);
}
const style = {
paddingLeft: `${message.indent + 15}px`,
textIndent: `-${message.indent}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>
{' '}
<Text coloredNick={coloredNick}>{message.content}</Text>
</span>
</p>
);
};
export default memo(Message);

View File

@ -1,306 +0,0 @@
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 { formatDate, measureScrollBarWidth } from 'utils';
import { getScrollPos, saveScrollPos } from 'utils/scrollPosition';
import { windowHeight } from 'utils/size';
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 = 150;
const scrollBarWidth = `${measureScrollBarWidth()}px`;
const hasSameLastMessage = (m1, m2) => {
if (m1.length === 0 || m2.length === 0) {
if (m1.length === 0 && m2.length === 0) {
return true;
}
return false;
}
return m1[m1.length - 1].id === m2[m2.length - 1].id;
};
export default class MessageBox extends PureComponent {
state = { topDate: '' };
list = createRef();
outer = createRef();
addMore = debounce(() => {
const { tab, onAddMore } = this.props;
this.ready = true;
onAddMore(tab.network, tab.name);
}, scrollbackDebounce);
constructor(props) {
super(props);
this.loadScrollPos();
}
componentDidUpdate(prevProps) {
const { messages } = this.props;
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 &&
!hasSameLastMessage(messages, prevProps.messages)
) {
this.list.current.scrollToItem(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 && prevProps.messages.length > 0) {
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;
}
if (index === messages.length + 1) {
return 7;
}
return messages[index - 1].height;
};
getItemKey = index => {
const { messages } = this.props;
if (index === 0) {
return 'top';
}
if (index === messages.length + 1) {
return 'bottom';
}
return messages[index - 1].id;
};
updateScrollKey = () => {
const { tab } = this.props;
this.scrollKey = `msg:${tab.network}:${tab.name}`;
return this.scrollKey;
};
loadScrollPos = scroll => {
const pos = getScrollPos(this.updateScrollKey());
const { messages } = this.props;
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(messages.length + 1);
} else if (messages.length > 0) {
let totalHeight = 14;
for (let i = 0; i < messages.length; i++) {
totalHeight += messages[i].height;
}
const messageBoxHeight = windowHeight() - 100;
if (totalHeight > messageBoxHeight) {
this.initialScrollTop = totalHeight;
}
}
}
};
saveScrollPos = () => {
if (this.bottom) {
saveScrollPos(this.scrollKey, -1);
} else {
saveScrollPos(this.scrollKey, this.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.scrollTop = scrollOffset;
this.bottom = scrollOffset + clientHeight >= scrollHeight - 20;
};
handleItemsRendered = ({ visibleStartIndex }) => {
const startIndex = visibleStartIndex === 0 ? 0 : visibleStartIndex - 1;
const firstVisibleMessage = this.props.messages[startIndex];
if (firstVisibleMessage && firstVisibleMessage.date) {
this.setState({ topDate: formatDate(firstVisibleMessage.date) });
} else {
this.setState({ topDate: '' });
}
};
handleMouseDown = () => {
this.mouseDown = true;
};
handleMouseUp = () => {
this.mouseDown = false;
if (this.shouldAdd) {
const { tab, onAddMore } = this.props;
this.shouldAdd = false;
onAddMore(tab.network, 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;
}
if (index === messages.length + 1) {
return null;
}
const { coloredNicks, onNickClick } = this.props;
const message = messages[index - 1];
return (
<div style={style}>
<Message
message={message}
coloredNick={coloredNicks}
onNickClick={onNickClick}
/>
</div>
);
};
render() {
const { messages, hideTopDate } = this.props;
const { topDate } = this.state;
const dateContainerStyle = {
right: scrollBarWidth
};
return (
<div
className="messagebox"
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
>
<div
className="messagebox-topdate-container"
style={dateContainerStyle}
>
{!hideTopDate && topDate && (
<span className="messagebox-topdate">{topDate}</span>
)}
</div>
<AutoSizer>
{({ width, height }) => (
<List
ref={this.list}
outerRef={this.outer}
width={width}
height={height}
itemCount={messages.length + 2}
itemKey={this.getItemKey}
itemSize={this.getRowHeight}
estimatedItemSize={32}
initialScrollOffset={this.initialScrollTop}
onScroll={this.handleScroll}
onItemsRendered={this.handleItemsRendered}
className="messagebox-window"
overscanCount={5}
>
{this.renderMessage}
</List>
)}
</AutoSizer>
</div>
);
}
}

View File

@ -1,68 +0,0 @@
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.network);
} else if (tab.name) {
onMessage(e.target.value, tab.name, tab.network);
}
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

@ -1,39 +0,0 @@
import React, { memo, useRef, useEffect } from 'react';
import { FiSearch } from 'react-icons/fi';
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">
<FiSearch className="search-input-icon" />
<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

@ -1,28 +0,0 @@
import React, { memo } from 'react';
import Text from 'components/Text';
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>
{' '}
<Text>{linkify(result.content)}</Text>
</span>
</p>
);
};
export default memo(SearchResult);

View File

@ -1,95 +0,0 @@
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;
}
if (index === users.length + 1) {
return 10;
}
return 24;
};
getItemKey = index => {
const { users } = this.props;
if (index === 0) {
return 'top';
}
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}
overscanCount={5}
>
{this.renderUser}
</List>
)}
</AutoSizer>
</div>
);
}
}

View File

@ -1,19 +0,0 @@
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

@ -1,247 +0,0 @@
import React, { Component } from 'react';
import { createSelector } from 'reselect';
import { Form, withFormik } from 'formik';
import { FiMoreHorizontal } from 'react-icons/fi';
import Navicon from 'components/ui/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()
);
const transformChannels = channels => {
const comma = channels[channels.length - 1] === ',';
channels = channels
.split(',')
.map(channel => {
channel = channel.trim();
if (channel) {
if (isValidChannel(channel, false) && channel[0] !== '#') {
channel = `#${channel}`;
}
}
return channel;
})
.filter(s => s)
.join(',');
return comma ? `${channels},` : channels;
};
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 className="connect-section">
<h2>SASL</h2>
<TextInput name="account" />
<TextInput name="password" type="password" noTrim />
</div>
{!hexIP && <TextInput name="username" />}
<TextInput
name="serverPassword"
label="Server Password"
type="password"
noTrim
/>
<TextInput name="realname" noTrim />
</>
);
};
transformPort = port => {
if (!port) {
return this.props.values.tls ? '6697' : '6667';
}
return port;
};
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" noTrim />
<div className="connect-form-address">
<TextInput name="host" noError />
<TextInput
name="port"
type="number"
blurTransform={this.transformPort}
noError
/>
<Checkbox
classNameLabel="connect-form-ssl"
name="tls"
label="SSL"
topLabel
onChange={this.handleSSLChange}
/>
</div>
<Error name="host" />
<Error name="port" />
<TextInput name="nick" />
<TextInput name="channels" transform={transformChannels} />
{this.state.showOptionals && this.renderOptionals()}
<Button
className="connect-form-button-optionals"
icon={FiMoreHorizontal}
aria-label="Show more"
onClick={this.handleShowClick}
/>
<Button type="submit">Connect</Button>
</Form>
);
}
return (
<div className="connect">
<Navicon />
{form}
</div>
);
}
}
export default withFormik({
enableReinitialize: true,
mapPropsToValues: ({ defaults, query }) => {
let port = '6667';
if (query.port || defaults.port) {
port = query.port || defaults.port;
} else if (defaults.ssl) {
port = '6697';
}
let { channels } = query;
if (channels) {
channels = transformChannels(channels);
}
let ssl;
if (query.ssl === 'true') {
ssl = true;
} else if (query.ssl === 'false') {
ssl = false;
} else {
ssl = defaults.ssl || false;
}
return {
name: query.name || defaults.name,
host: query.host || defaults.host,
port,
nick: query.nick || localStorage.lastNick || '',
channels: channels || defaults.channels.join(','),
account: '',
password: '',
username: query.username || '',
serverPassword: defaults.serverPassword ? ' ' : '',
realname: query.realname || localStorage.lastRealname || '',
tls: ssl
};
},
validate: values => {
const errors = {};
if (!values.host) {
errors.host = 'Host is required';
} else if (values.host.indexOf('.') < 1) {
errors.host = 'Invalid host';
}
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';
}
const channels = values.channels.split(',');
for (let i = channels.length - 1; i >= 0; i--) {
if (i === channels.length - 1 && channels[i] === '') {
/* eslint-disable-next-line no-continue */
continue;
}
if (!isValidChannel(channels[i])) {
errors.channels = 'Invalid channel(s)';
break;
}
}
return errors;
},
handleSubmit: (values, { props }) => {
const { connect, select, join } = props;
const channels = values.channels ? values.channels.split(',') : [];
delete values.channels;
values.port = `${values.port}`;
connect(values);
select(values.host);
if (channels.length > 0) {
join(channels, values.host, false);
}
localStorage.lastNick = values.nick;
if (values.realname) {
localStorage.lastRealname = values.realname;
}
}
})(Connect);

View File

@ -1,87 +0,0 @@
import React, { useCallback } from 'react';
import Navicon from 'components/ui/Navicon';
import Button from 'components/ui/Button';
import Checkbox from 'components/ui/Checkbox';
import FileInput from 'components/ui/FileInput';
const Settings = ({
settings,
installable,
version,
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="settings-button 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>
{version && (
<div className="settings-version">
<p>{version.tag}</p>
<p>Commit: {version.commit}</p>
<p>Build Date: {version.date}</p>
</div>
)}
</div>
</div>
);
};
export default Settings;

View File

@ -1,21 +0,0 @@
import React from 'react';
import cn from 'classnames';
const Button = ({ children, category, className, icon: Icon, ...props }) => (
<button
className={cn(
{
[`button-${category}`]: category,
'icon-button': Icon && !children
},
className
)}
type="button"
{...props}
>
{Icon && <Icon />}
{children}
</button>
);
export default Button;

View File

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

View File

@ -1,111 +0,0 @@
import React, { PureComponent, createRef } from 'react';
import cn from 'classnames';
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();
} else 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, editable, value } = this.props;
const style = {
width: this.state.width,
textIndent: this.state.indent,
paddingLeft: 0
};
return this.state.editing ? (
<input
ref={this.inputEl}
className={`editable-wrap ${className}`}
type="text"
value={value}
onBlur={this.handleBlur}
onChange={this.handleChange}
onKeyDown={this.handleKey}
onFocus={this.handleFocus}
style={style}
spellCheck={false}
/>
) : (
<div
className={cn('editable-wrap', {
'editable-wrap-editable': editable
})}
onClick={this.startEditing}
>
{children}
</div>
);
}
}

View File

@ -1,48 +0,0 @@
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

@ -1,19 +0,0 @@
import React from 'react';
import { FiMenu } from 'react-icons/fi';
import { useDispatch } from 'react-redux';
import Button from 'components/ui/Button';
import { toggleMenu } from 'state/ui';
const Navicon = () => {
const dispatch = useDispatch();
return (
<Button
className="navicon"
icon={FiMenu}
onClick={() => dispatch(toggleMenu())}
/>
);
};
export default Navicon;

View File

@ -1,128 +0,0 @@
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,
noTrim,
transform,
blurTransform,
...props
} = this.props;
return (
<FastField
name={name}
render={({ field, form }) => (
<>
<div className="textinput">
<input
className={field.value && 'value'}
type="text"
name={name}
id={name}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck="false"
ref={this.input}
onFocus={this.handleFocus}
{...field}
{...props}
onChange={e => {
let v = e.target.value;
if (!noTrim) {
v = v.trim();
}
if (transform) {
v = transform(v);
}
if (v !== field.value) {
form.setFieldValue(name, v);
if (props.onChange) {
props.onChange(e);
}
}
}}
onBlur={e => {
field.onBlur(e);
if (props.onBlur) {
props.onBlur(e);
}
if (blurTransform) {
const v = blurTransform(e.target.value);
if (v && v !== field.value) {
form.setFieldValue(name, v);
}
}
}}
/>
<label
htmlFor={name}
className={classnames('textinput-label', 'textinput-1', {
value: field.value,
error: form.touched[name] && form.errors[name]
})}
>
{label}
</label>
<span
className={classnames('textinput-label', 'textinput-2', {
value: field.value,
error: form.touched[name] && form.errors[name]
})}
>
{label}
</span>
</div>
{!noError && <Error name={name} />}
</>
)}
/>
);
}
}

View File

@ -1,24 +0,0 @@
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 }) => (
<Checkbox
name={name}
checked={field.value}
onChange={e => {
field.onChange(e);
if (onChange) {
onChange(e);
}
}}
{...props}
/>
)}
/>
);
export default memo(FormikCheckbox);

View File

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

View File

@ -1,88 +0,0 @@
import { bindActionCreators } from 'redux';
import { createStructuredSelector } from 'reselect';
import Chat from 'components/pages/Chat';
import { getSelectedTabTitle } from 'state';
import {
getSelectedChannel,
getSelectedChannelUsers,
part
} from 'state/channels';
import {
getCurrentInputHistoryEntry,
addInputHistory,
resetInputHistory,
incrementInputHistory,
decrementInputHistory
} from 'state/input';
import {
getSelectedMessages,
getHasMoreMessages,
runCommand,
sendMessage,
fetchMessages,
addFetchedMessages
} from 'state/messages';
import { openModal } from 'state/modals';
import { openPrivateChat, closePrivateChat } from 'state/privateChats';
import { getSearch, searchMessages, toggleSearch } from 'state/search';
import {
getCurrentNick,
getCurrentNetworkError,
disconnect,
setNick,
setNetworkName
} from 'state/networks';
import { getSettings } from 'state/settings';
import { getSelectedTab, select } from 'state/tab';
import { getShowUserList, toggleUserList } from 'state/ui';
import connect from 'utils/connect';
const mapState = createStructuredSelector({
channel: getSelectedChannel,
currentInputHistoryEntry: getCurrentInputHistoryEntry,
hasMoreMessages: getHasMoreMessages,
messages: getSelectedMessages,
nick: getCurrentNick,
search: getSearch,
showUserList: getShowUserList,
error: getCurrentNetworkError,
tab: getSelectedTab,
title: getSelectedTabTitle,
users: getSelectedChannelUsers,
coloredNicks: state => getSettings(state).coloredNicks
});
const mapDispatch = dispatch => ({
...bindActionCreators(
{
addFetchedMessages,
closePrivateChat,
disconnect,
fetchMessages,
openModal,
openPrivateChat,
part,
runCommand,
searchMessages,
select,
sendMessage,
setNick,
setNetworkName,
toggleSearch,
toggleUserList
},
dispatch
),
inputActions: bindActionCreators(
{
add: addInputHistory,
reset: resetInputHistory,
increment: incrementInputHistory,
decrement: decrementInputHistory
},
dispatch
)
});
export default connect(mapState, mapDispatch)(Chat);

View File

@ -1,27 +0,0 @@
import { createStructuredSelector } from 'reselect';
import Settings from 'components/pages/Settings';
import { appSet } from 'state/app';
import {
getSettings,
setSetting,
setCert,
setKey,
uploadCert
} from 'state/settings';
import connect from 'utils/connect';
const mapState = createStructuredSelector({
settings: getSettings,
installable: state => state.app.installable,
version: state => state.app.version
});
const mapDispatch = {
onCertChange: setCert,
onKeyChange: setKey,
uploadCert,
setSetting,
onInstall: () => appSet('installable', null)
};
export default connect(mapState, mapDispatch)(Settings);

View File

@ -1,17 +0,0 @@
import { createStructuredSelector } from 'reselect';
import get from 'lodash/get';
import TabListItem from 'components/TabListItem';
import connect from 'utils/connect';
const mapState = createStructuredSelector({
error: (state, { network, target }) => {
const messages = get(state, ['messages', network, target]);
if (messages && messages.length > 0) {
return messages[messages.length - 1].type === 'error';
}
return false;
}
});
export default connect(mapState)(TabListItem);

View File

@ -1,8 +0,0 @@
import { setConfig } from 'react-hot-loader';
import ReactDOM from 'react-dom';
setConfig({
ignoreSFC: !!ReactDOM.setHotElementComparator,
pureSFC: true,
pureRender: true
});

View File

@ -1,35 +0,0 @@
import './hot';
import React from 'react';
import { render } from 'react-dom';
import Root from 'components/Root';
import { appSet } from 'state/app';
import initRouter from 'utils/router';
import Socket from 'utils/Socket';
import configureStore from './store';
import routes from './routes';
import runModules from './modules';
import { register } from './serviceWorker';
import '../css/fonts.css';
import '../css/style.css';
const production = process.env.NODE_ENV === 'production';
const host = production
? window.location.host
: `${window.location.hostname}:1337`;
const socket = new Socket(host);
const store = configureStore(socket);
initRouter(routes, store);
runModules({ store, socket });
render(<Root store={store} />, document.getElementById('root'));
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
store.dispatch(appSet('installable', e));
});
register({
onUpdate: () => store.dispatch(appSet('newVersionAvailable', true))
});

View File

@ -1,27 +0,0 @@
import capitalize from 'lodash/capitalize';
import { getRouter } from 'state';
import { getCurrentNetworkName } from 'state/networks';
import { observe } from 'utils/observe';
export default function documentTitle({ store }) {
observe(store, [getRouter, getCurrentNetworkName], (router, networkName) => {
let title;
if (router.route === 'chat') {
const { network, name } = router.params;
if (name) {
title = `${name} @ ${networkName || network}`;
} else {
title = networkName || network;
}
} else {
title = capitalize(router.route);
}
if (title) {
document.title = `${title} | Dispatch`;
} else {
document.title = 'Dispatch';
}
});
}

View File

@ -1,15 +0,0 @@
import { setCharWidth } from 'state/app';
import { stringWidth } from 'utils';
export default async function fonts({ store }) {
let { charWidth } = localStorage;
if (charWidth) {
store.dispatch(setCharWidth(parseFloat(charWidth)));
} else {
await document.fonts.load('16px Roboto Mono');
charWidth = stringWidth(' ', '16px Roboto Mono');
store.dispatch(setCharWidth(charWidth));
localStorage.charWidth = charWidth;
}
}

View File

@ -1,47 +0,0 @@
import { INIT } from 'state/actions';
import { getConnected, getWrapWidth } from 'state/app';
import { searchChannels } from 'state/channelSearch';
import { addMessages } from 'state/messages';
import { when } from 'utils/observe';
function loadState({ store }, env) {
store.dispatch({
type: INIT,
settings: env.settings,
networks: env.networks,
channels: env.channels,
openDMs: env.openDMs,
users: env.users,
app: {
connectDefaults: env.defaults,
initialized: true,
hexIP: env.hexIP,
version: env.version
}
});
if (env.messages) {
// Wait until wrapWidth gets initialized so that height calculations
// only happen once for these messages
when(store, getWrapWidth, () => {
const { messages, network, to, next } = env.messages;
store.dispatch(addMessages(messages, network, to, false, next));
});
}
if (env.networks) {
when(store, getConnected, () =>
// Cache top channels for each network
env.networks.forEach(({ host }) =>
store.dispatch(searchChannels(host, ''))
)
);
}
}
/* eslint-disable no-underscore-dangle */
export default async function initialState(ctx) {
const env = await window.__init__;
ctx.socket.connect();
loadState(ctx, env);
}

View File

@ -1,22 +0,0 @@
import { updateSelection } from 'state/tab';
import { observe, when } from 'utils/observe';
export default function route({ store }) {
let first = true;
when(
store,
state => state.app.initialized,
() =>
observe(
store,
state => state.router,
router => {
if (!router.route || router.route === 'chat') {
store.dispatch(updateSelection(first));
first = false;
}
}
)
);
}

View File

@ -1,196 +0,0 @@
import { socketAction } from 'state/actions';
import { kicked } from 'state/channels';
import {
print,
addMessage,
addMessages,
addEvent,
broadcastEvent
} from 'state/messages';
import { openModal } from 'state/modals';
import { reconnect } from 'state/networks';
import { select } from 'state/tab';
import { find } from 'utils';
function findChannels(state, network, user) {
const channels = [];
Object.keys(state.channels[network]).forEach(channel => {
if (find(state.channels[network][channel].users, u => u.nick === user)) {
channels.push(channel);
}
});
return channels;
}
export default function handleSocket({
socket,
store: { dispatch, getState }
}) {
const handlers = {
message(message) {
dispatch(addMessage(message, message.network, message.to));
return false;
},
pm(message) {
dispatch(addMessage(message, message.network, message.from));
return false;
},
messages({ messages, network, to, prepend, next }) {
dispatch(addMessages(messages, network, to, prepend, next));
return false;
},
join({ user, network, channels }) {
dispatch(addEvent(network, channels[0], 'join', user));
},
part({ user, network, channel, reason }) {
dispatch(addEvent(network, channel, 'part', user, reason));
},
quit({ user, network, reason }) {
const channels = findChannels(getState(), network, user);
dispatch(broadcastEvent(network, channels, 'quit', user, reason));
},
kick({ network, channel, sender, user, reason }) {
dispatch(kicked(network, channel, user));
dispatch(addEvent(network, channel, 'kick', user, sender, reason));
},
nick({ network, oldNick, newNick }) {
if (oldNick) {
const channels = findChannels(getState(), network, oldNick);
dispatch(broadcastEvent(network, channels, 'nick', oldNick, newNick));
}
},
topic({ network, channel, topic, nick }) {
if (nick) {
dispatch(addEvent(network, channel, 'topic', nick, topic));
}
},
motd({ content, network }) {
dispatch(
addMessages(
content.map(line => ({ content: line })),
network
)
);
return false;
},
whois(data) {
const tab = getState().tab.selected;
dispatch(
print(
[
`Nick: ${data.nick}`,
`Username: ${data.username}`,
`Realname: ${data.realname}`,
`Host: ${data.host}`,
`Server: ${data.server}`,
`Channels: ${data.channels}`
],
tab.network,
tab.name
)
);
return false;
},
print(message) {
const tab = getState().tab.selected;
dispatch(addMessage(message, tab.network, tab.name));
return false;
},
error({ network, target, message }) {
const state = getState();
const tab = state.tab.selected;
if (network === tab.network) {
// Print it in the current channel if the error happened on
// the current network
target = tab.name;
} else if (!state.channels[network]?.[target]) {
// Print it the network tab if the target does not exist
target = null;
}
dispatch(
addMessage({ content: message, type: 'error' }, network, target)
);
return false;
},
connection_update({ network, errorType }) {
if (errorType === 'verify') {
dispatch(
openModal('confirm', {
question:
'The network is using a self-signed certificate, continue anyway?',
onConfirm: () =>
dispatch(
reconnect(network, {
skipVerify: true
})
)
})
);
}
},
dcc_send({ network, from, filename, size, url }) {
const networkName = getState().networks[network]?.name || network;
dispatch(
openModal('confirm', {
question: `${from} on ${networkName} is sending you (${size}): ${filename}`,
confirmation: 'Download',
onConfirm: () => {
const a = document.createElement('a');
a.href = url;
a.click();
}
})
);
}
};
const afterHandlers = {
channel_forward(forward) {
const { selected } = getState().tab;
if (
selected.network === forward.network &&
selected.name === forward.old
) {
dispatch(select(forward.network, forward.new, true));
}
}
};
socket.onMessage((type, data) => {
let action;
if (Array.isArray(data)) {
action = { type: socketAction(type), data: [...data] };
} else {
action = { ...data, type: socketAction(type) };
}
if (handlers[type]?.(data) === false) {
return;
}
dispatch(action);
afterHandlers[type]?.(data);
});
}

View File

@ -1,88 +0,0 @@
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swUrl = '/sw.js';
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

View File

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

View File

@ -1,430 +0,0 @@
import reducer, { compareUsers, getSortedChannels } from '../channels';
import { connect } from '../networks';
import * as actions from '../actions';
describe('channel reducer', () => {
it('removes channels on PART', () => {
let state = {
srv1: {
chan1: {},
chan2: {},
chan3: {}
},
srv2: {
chan1: {}
}
};
state = reducer(state, {
type: actions.PART,
network: 'srv1',
channels: ['chan1', 'chan3']
});
expect(state).toEqual({
srv1: {
chan2: {}
},
srv2: {
chan1: {}
}
});
});
it('handles SOCKET_PART', () => {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, socket_join('srv', 'chan1', 'nick2'));
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.socket.PART,
network: 'srv',
channel: 'chan1',
user: 'nick2'
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
},
chan2: {
name: 'chan2',
joined: true,
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
});
it('handles SOCKET_JOIN', () => {
const state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
}
}
});
});
it('handles SOCKET_QUIT', () => {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, socket_join('srv', 'chan1', 'nick2'));
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.socket.QUIT,
network: 'srv',
user: 'nick2'
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
},
chan2: {
name: 'chan2',
joined: true,
users: []
}
}
});
});
it('handles KICKED', () => {
let state = reducer(
undefined,
connect({
host: 'srv',
nick: 'nick2'
})
);
state = reducer(state, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, socket_join('srv', 'chan1', 'nick2'));
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.KICKED,
network: 'srv',
channel: 'chan2',
user: 'nick2',
self: true
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [
{ mode: '', nick: 'nick1', renderName: 'nick1' },
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
},
chan2: {
name: 'chan2',
joined: false,
users: []
}
}
});
state = reducer(state, {
type: actions.KICKED,
network: 'srv',
channel: 'chan1',
user: 'nick1'
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
},
chan2: {
name: 'chan2',
joined: false,
users: []
}
}
});
});
it('handles SOCKET_NICK', () => {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, socket_join('srv', 'chan1', 'nick2'));
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.socket.NICK,
network: 'srv',
oldNick: 'nick1',
newNick: 'nick3'
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [
{ mode: '', nick: 'nick3', renderName: 'nick3' },
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
},
chan2: {
name: 'chan2',
joined: true,
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
});
it('handles SOCKET_USERS', () => {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, {
type: actions.socket.USERS,
network: 'srv',
channel: 'chan1',
users: ['user3', 'user2', '@user4', 'user1', '+user5']
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [
{ mode: '', nick: 'user3', renderName: 'user3' },
{ mode: '', nick: 'user2', renderName: 'user2' },
{ mode: 'o', nick: 'user4', renderName: '@user4' },
{ mode: '', nick: 'user1', renderName: 'user1' },
{ mode: 'v', nick: 'user5', renderName: '+user5' }
]
}
}
});
});
it('handles SOCKET_TOPIC', () => {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, {
type: actions.socket.TOPIC,
network: 'srv',
channel: 'chan1',
topic: 'the topic'
});
expect(state).toMatchObject({
srv: {
chan1: {
topic: 'the topic'
}
}
});
});
it('handles SOCKET_MODE', () => {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, socket_join('srv', 'chan1', 'nick2'));
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', ''));
expect(state).toMatchObject({
srv: {
chan1: {
users: [
{ mode: 'o', nick: 'nick1', renderName: '@nick1' },
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
},
chan2: {
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'v', 'o'));
state = reducer(state, socket_mode('srv', 'chan1', 'nick2', 'o', ''));
state = reducer(state, socket_mode('srv', 'chan2', 'not_there', 'x', ''));
expect(state).toMatchObject({
srv: {
chan1: {
users: [
{ mode: 'v', nick: 'nick1', renderName: '+nick1' },
{ mode: 'o', nick: 'nick2', renderName: '@nick2' }
]
},
chan2: {
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
});
it('handles channels from INIT', () => {
const state = reducer(undefined, {
type: actions.INIT,
channels: [
{ network: 'srv', name: 'chan1', topic: 'the topic' },
{ network: 'srv', name: 'chan2', joined: true },
{ network: 'srv2', name: 'chan1' }
]
});
expect(state).toEqual({
srv: {
chan1: { name: 'chan1', topic: 'the topic', users: [] },
chan2: { name: 'chan2', joined: true, users: [] }
},
srv2: {
chan1: { name: 'chan1', users: [] }
}
});
});
it('handles networks from INIT', () => {
const state = reducer(undefined, {
type: actions.INIT,
networks: [{ host: '127.0.0.1' }, { host: 'thehost' }]
});
expect(state).toEqual({
'127.0.0.1': {},
thehost: {}
});
});
it('optimistically adds the network on CONNECT', () => {
const state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
expect(state).toEqual({
'127.0.0.1': {}
});
});
it('removes the network on DISCONNECT', () => {
let state = {
srv: {},
srv2: {}
};
state = reducer(state, {
type: actions.DISCONNECT,
network: 'srv2'
});
expect(state).toEqual({
srv: {}
});
});
});
function socket_join(network, channel, user) {
return {
type: actions.socket.JOIN,
network,
user,
channels: [channel]
};
}
function socket_mode(network, channel, user, add, remove) {
return {
type: actions.socket.MODE,
network,
channel,
user,
add,
remove
};
}
describe('compareUsers()', () => {
it('compares users correctly', () => {
expect(
[
{ renderName: 'user5' },
{ renderName: '@user2' },
{ renderName: 'user3' },
{ renderName: 'user2' },
{ renderName: '+user1' },
{ renderName: '~bob' },
{ renderName: '%apples' },
{ renderName: '&cake' }
].sort(compareUsers)
).toEqual([
{ renderName: '~bob' },
{ renderName: '&cake' },
{ renderName: '@user2' },
{ renderName: '%apples' },
{ renderName: '+user1' },
{ renderName: 'user2' },
{ renderName: 'user3' },
{ renderName: 'user5' }
]);
});
});
describe('getSortedChannels', () => {
it('sorts networks and channels', () => {
expect(
getSortedChannels({
channels: {
'bob.com': {},
'127.0.0.1': {
'#chan1': {
name: '#chan1',
users: [],
topic: 'cake'
},
'#pie': {
name: '#pie'
},
'##apples': {
name: '##apples'
}
}
}
})
).toEqual([
{
address: '127.0.0.1',
channels: [
{
name: '##apples'
},
{
name: '#chan1',
users: [],
topic: 'cake'
},
{
name: '#pie'
}
]
},
{
address: 'bob.com',
channels: []
}
]);
});
});

View File

@ -1,303 +0,0 @@
import reducer, { broadcast, getMessageTab } from '../messages';
import * as actions from '../actions';
import appReducer from '../app';
import { unix } from 'utils';
describe('message reducer', () => {
it('adds the message on ADD_MESSAGE', () => {
const state = reducer(undefined, {
type: actions.ADD_MESSAGE,
network: 'srv',
tab: '#chan1',
message: {
from: 'foo',
content: 'msg'
}
});
expect(state).toMatchObject({
srv: {
'#chan1': [
{
from: 'foo',
content: [{ type: 'text', text: 'msg' }]
}
]
}
});
});
it('adds all the messages on ADD_MESSAGES', () => {
const state = reducer(undefined, {
type: actions.ADD_MESSAGES,
network: 'srv',
tab: '#chan1',
messages: [
{
from: 'foo',
content: 'msg'
},
{
from: 'bar',
content: 'msg'
},
{
tab: '#chan2',
from: 'foo',
content: 'msg'
}
]
});
expect(state).toMatchObject({
srv: {
'#chan1': [
{
from: 'foo',
content: [{ type: 'text', text: 'msg' }]
},
{
from: 'bar',
content: [{ type: 'text', text: 'msg' }]
}
],
'#chan2': [
{
from: 'foo',
content: [{ type: 'text', text: 'msg' }]
}
]
}
});
});
it('handles prepending of messages on ADD_MESSAGES', () => {
let state = {
srv: {
'#chan1': [{ id: 0 }]
}
};
state = reducer(state, {
type: actions.ADD_MESSAGES,
network: 'srv',
tab: '#chan1',
prepend: true,
messages: [
{ id: 1, date: new Date() },
{ id: 2, date: new Date() }
]
});
expect(state).toMatchObject({
srv: {
'#chan1': [{ id: 1 }, { id: 2 }, { id: 0 }]
}
});
});
it('adds date markers when prepending messages', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1990, 0, 3) }]
}
};
state = reducer(state, {
type: actions.ADD_MESSAGES,
network: 'srv',
tab: '#chan1',
prepend: true,
messages: [
{ id: 1, time: unix(new Date(1990, 0, 1)) },
{ id: 2, time: unix(new Date(1990, 0, 2)) }
]
});
expect(state).toMatchObject({
srv: {
'#chan1': [
{ id: 1 },
{ type: 'date' },
{ id: 2 },
{ type: 'date' },
{ id: 0 }
]
}
});
});
it('adds a date marker when adding a message', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1999, 0, 1) }]
}
};
state = reducer(state, {
type: actions.ADD_MESSAGE,
network: 'srv',
tab: '#chan1',
message: { id: 1, date: new Date(1990, 0, 2) }
});
expect(state).toMatchObject({
srv: {
'#chan1': [{ id: 0 }, { type: 'date' }, { id: 1 }]
}
});
});
it('adds date markers when adding messages', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1990, 0, 1) }]
}
};
state = reducer(state, {
type: actions.ADD_MESSAGES,
network: 'srv',
tab: '#chan1',
messages: [
{ id: 1, time: unix(new Date(1990, 0, 2)) },
{ id: 2, time: unix(new Date(1990, 0, 3)) },
{ id: 3, time: unix(new Date(1990, 0, 3)) }
]
});
expect(state).toMatchObject({
srv: {
'#chan1': [
{ id: 0 },
{ type: 'date' },
{ id: 1 },
{ type: 'date' },
{ id: 2 },
{ id: 3 }
]
}
});
});
it('adds messages to the correct tabs when broadcasting', () => {
let state = {
app: appReducer(undefined, { type: '' })
};
const thunk = broadcast('test', 'srv', ['#chan1', '#chan3']);
thunk(
action => {
state.messages = reducer(undefined, action);
},
() => state
);
const messages = state.messages;
expect(messages.srv).not.toHaveProperty('srv');
expect(messages.srv['#chan1']).toHaveLength(1);
expect(messages.srv['#chan1'][0].content).toMatchObject([
{ type: 'text', text: 'test' }
]);
expect(messages.srv['#chan3']).toHaveLength(1);
expect(messages.srv['#chan3'][0].content).toMatchObject([
{ type: 'text', text: 'test' }
]);
});
it('deletes all messages related to network when disconnecting', () => {
let state = {
srv: {
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [{ content: 'msg' }]
}
};
state = reducer(state, {
type: actions.DISCONNECT,
network: 'srv'
});
expect(state).toEqual({
srv2: {
'#chan1': [{ content: 'msg' }]
}
});
});
it('deletes all messages related to channel when parting', () => {
let state = {
srv: {
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [{ content: 'msg' }]
}
};
state = reducer(state, {
type: actions.PART,
network: 'srv',
channels: ['#chan1']
});
expect(state).toEqual({
srv: {
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [{ content: 'msg' }]
}
});
});
it('deletes direct messages when closing a direct message tab', () => {
let state = {
srv: {
bob: [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [{ content: 'msg' }]
}
};
state = reducer(state, {
type: actions.CLOSE_PRIVATE_CHAT,
network: 'srv',
nick: 'bob'
});
expect(state).toEqual({
srv: {
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [{ content: 'msg' }]
}
});
});
});
describe('getMessageTab()', () => {
it('returns the correct tab', () => {
const srv = 'chat.freenode.net';
[
['#cake', '#cake'],
['#apple.pie', '#apple.pie'],
['bob', 'bob'],
[undefined, srv],
[null, srv],
['*', srv],
[srv, srv],
['beans.freenode.net', srv]
].forEach(([target, expected]) =>
expect(getMessageTab(srv, target)).toBe(expected)
);
});
});

View File

@ -1,65 +0,0 @@
import createReducer from 'utils/createReducer';
import * as actions from './actions';
export const getApp = state => state.app;
export const getConnected = state => state.app.connected;
export const getWrapWidth = state => state.app.wrapWidth;
export const getCharWidth = state => state.app.charWidth;
export const getWindowWidth = state => state.app.windowWidth;
export const getConnectDefaults = state => state.app.connectDefaults;
const initialState = {
connected: false,
wrapWidth: 0,
charWidth: 0,
windowWidth: 0,
connectDefaults: {
name: '',
host: '',
port: '',
channels: [],
ssl: false,
password: false,
readonly: false,
showDetails: false
},
hexIP: false,
newVersionAvailable: false,
installable: null
};
export default createReducer(initialState, {
[actions.APP_SET](state, { key, value }) {
if (typeof key === 'object') {
Object.assign(state, key);
} else {
state[key] = value;
}
},
[actions.socket.CONNECTED](state, { connected }) {
state.connected = connected;
},
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
state.wrapWidth = action.wrapWidth;
state.charWidth = action.charWidth;
state.windowWidth = action.windowWidth;
},
[actions.INIT](state, { app }) {
Object.assign(state, app);
}
});
export function appSet(key, value) {
return {
type: actions.APP_SET,
key,
value
};
}
export function setCharWidth(width) {
return appSet('charWidth', width);
}

View File

@ -1,47 +0,0 @@
import createReducer from 'utils/createReducer';
import * as actions from 'state/actions';
const initialState = {
results: [],
end: false,
topCache: {}
};
export default createReducer(initialState, {
[actions.socket.CHANNEL_SEARCH](state, { results, start, network, q }) {
if (results) {
state.end = false;
if (start > 0) {
state.results.push(...results);
} else {
state.results = results;
if (!q) {
state.topCache[network] = results;
}
}
} else {
state.end = true;
}
},
[actions.OPEN_MODAL](state, { name, payload }) {
if (name === 'channel') {
state.results = state.topCache[payload] || [];
state.end = false;
}
}
});
export function searchChannels(network, q, start) {
return {
type: actions.CHANNEL_SEARCH,
network,
q,
socket: {
type: 'channel_search',
data: { network, q, start }
}
};
}

View File

@ -1,332 +0,0 @@
import { createSelector } from 'reselect';
import get from 'lodash/get';
import sortBy from 'lodash/sortBy';
import createReducer from 'utils/createReducer';
import { trimPrefixChar, find, findIndex } from 'utils';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
const modePrefixes = [
{ mode: 'q', prefix: '~' }, // Owner
{ mode: 'a', prefix: '&' }, // Admin
{ mode: 'o', prefix: '@' }, // Op
{ mode: 'h', prefix: '%' }, // Halfop
{ mode: 'v', prefix: '+' } // Voice
];
function getRenderName(user) {
for (let i = 0; i < modePrefixes.length; i++) {
if (user.mode.indexOf(modePrefixes[i].mode) !== -1) {
return `${modePrefixes[i].prefix}${user.nick}`;
}
}
return user.nick;
}
function createUser(nick, mode) {
const user = {
nick,
mode: mode || ''
};
user.renderName = getRenderName(user);
return user;
}
function loadUser(nick) {
let mode;
for (let i = 0; i < modePrefixes.length; i++) {
if (nick[0] === modePrefixes[i].prefix) {
({ mode } = modePrefixes[i]);
}
}
if (mode) {
return createUser(nick.slice(1), mode);
}
return createUser(nick);
}
function removeUser(users, nick) {
const i = findIndex(users, u => u.nick === nick);
if (i !== -1) {
users.splice(i, 1);
}
}
function init(state, network, channel) {
if (!state[network]) {
state[network] = {};
}
if (channel && !state[network][channel]) {
state[network][channel] = {
name: channel,
users: [],
joined: false
};
}
return state[network][channel];
}
export function compareUsers(a, b) {
a = a.renderName.toLowerCase();
b = b.renderName.toLowerCase();
for (let i = 0; i < modePrefixes.length; i++) {
const { prefix } = modePrefixes[i];
if (a[0] === prefix && b[0] !== prefix) {
return -1;
}
if (b[0] === prefix && a[0] !== prefix) {
return 1;
}
}
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
export const getChannels = state => state.channels;
export const getSortedChannels = createSelector(getChannels, channels =>
sortBy(
Object.keys(channels).map(network => ({
address: network,
channels: sortBy(channels[network], channel =>
trimPrefixChar(channel.name, '#').toLowerCase()
)
})),
network => network.address.toLowerCase()
)
);
export const getSelectedChannel = createSelector(
getSelectedTab,
getChannels,
(tab, channels) => get(channels, [tab.network, tab.name])
);
export const getSelectedChannelUsers = createSelector(
getSelectedChannel,
channel => {
if (channel) {
return channel.users.concat().sort(compareUsers);
}
return [];
}
);
export default createReducer(
{},
{
[actions.JOIN](state, { network, channels }) {
channels.forEach(channel => init(state, network, channel));
},
[actions.PART](state, { network, channels }) {
channels.forEach(channel => delete state[network][channel]);
},
[actions.socket.JOIN](state, { network, channels, user }) {
const channel = channels[0];
const chan = init(state, network, channel);
chan.name = channel;
chan.joined = true;
chan.users.push(createUser(user));
},
[actions.socket.CHANNEL_FORWARD](state, action) {
init(state, action.network, action.new);
delete state[action.network][action.old];
},
[actions.socket.PART](state, { network, channel, user }) {
if (state[network][channel]) {
removeUser(state[network][channel].users, user);
}
},
[actions.socket.QUIT](state, { network, user }) {
Object.keys(state[network]).forEach(channel => {
removeUser(state[network][channel].users, user);
});
},
[actions.KICKED](state, { network, channel, user, self }) {
const chan = state[network][channel];
if (self) {
chan.joined = false;
chan.users = [];
} else {
removeUser(chan.users, user);
}
},
[actions.socket.NICK](state, { network, oldNick, newNick }) {
Object.keys(state[network]).forEach(channel => {
const user = find(
state[network][channel].users,
u => u.nick === oldNick
);
if (user) {
user.nick = newNick;
user.renderName = getRenderName(user);
}
});
},
[actions.socket.MODE](state, { network, channel, user, remove, add }) {
const u = find(state[network][channel].users, v => v.nick === user);
if (u) {
if (remove) {
let j = remove.length;
while (j--) {
u.mode = u.mode.replace(remove[j], '');
}
}
if (add) {
u.mode += add;
}
u.renderName = getRenderName(u);
}
},
[actions.socket.TOPIC](state, { network, channel, topic }) {
state[network][channel].topic = topic;
},
[actions.socket.USERS](state, { network, channel, users }) {
state[network][channel].users = users.map(nick => loadUser(nick));
},
[actions.INIT](state, { networks, channels, users }) {
if (networks) {
networks.forEach(({ host }) => init(state, host));
}
if (channels) {
channels.forEach(({ network, name, topic, joined }) => {
const chan = init(state, network, name);
chan.joined = joined;
chan.topic = topic;
});
}
if (users) {
state[users.network][users.channel].users = users.users.map(nick =>
loadUser(nick)
);
}
},
[actions.CONNECT](state, { host }) {
init(state, host);
},
[actions.DISCONNECT](state, { network }) {
delete state[network];
}
}
);
export function join(channels, network, selectFirst = true) {
return {
type: actions.JOIN,
channels,
network,
selectFirst,
socket: {
type: 'join',
data: { channels, network }
}
};
}
export function part(channels, network) {
return (dispatch, getState) => {
const action = {
type: actions.PART,
channels,
network
};
const state = getState().channels[network];
const joined = channels.filter(c => state[c] && state[c].joined);
if (joined.length > 0) {
action.socket = {
type: 'part',
data: {
channels: joined,
network
}
};
}
dispatch(action);
dispatch(updateSelection());
};
}
export function invite(user, channel, network) {
return {
type: actions.INVITE,
user,
channel,
network,
socket: {
type: 'invite',
data: { user, channel, network }
}
};
}
export function kick(user, channel, network) {
return {
type: actions.KICK,
user,
channel,
network,
socket: {
type: 'kick',
data: { user, channel, network }
}
};
}
export function kicked(network, channel, user) {
return (dispatch, getState) => {
const nick = getState().networks[network]?.nick;
dispatch({
type: actions.KICKED,
network,
channel,
user,
self: nick === user
});
};
}
export function setTopic(topic, channel, network) {
return {
type: actions.SET_TOPIC,
topic,
channel,
network,
socket: {
type: 'topic',
data: { topic, channel, network }
}
};
}

View File

@ -1,663 +0,0 @@
import { createSelector } from 'reselect';
import has from 'lodash/has';
import {
findBreakpoints,
messageHeight,
linkify,
timestamp,
isChannel,
formatDate,
unix
} from 'utils';
import colorify from 'utils/colorify';
import createReducer from 'utils/createReducer';
import { getApp } from './app';
import { getSelectedTab } from './tab';
import * as actions from './actions';
export const getMessages = state => state.messages;
export const getSelectedMessages = createSelector(
getSelectedTab,
getMessages,
(tab, messages) => {
const target = tab.name || tab.network;
if (has(messages, [tab.network, target])) {
return messages[tab.network][target];
}
return [];
}
);
export const getHasMoreMessages = createSelector(
getSelectedMessages,
messages => {
const first = messages[0];
return first && first.next;
}
);
function init(state, network, tab) {
if (!state[network]) {
state[network] = {};
}
if (!state[network][tab]) {
state[network][tab] = [];
}
}
function initNetworks(state, networks = []) {
networks.forEach(({ host }) => {
state[host] = {};
});
}
const collapsedEvents = ['join', 'part', 'quit', 'nick'];
function shouldCollapse(msg1, msg2) {
return (
msg1.events &&
msg2.events &&
collapsedEvents.indexOf(msg1.events[0].type) !== -1 &&
collapsedEvents.indexOf(msg2.events[0].type) !== -1
);
}
const blocks = {
nick: nick => ({ type: 'nick', text: nick }),
text: text => ({ type: 'text', text }),
events: count => ({ type: 'events', text: `${count} more` })
};
const eventVerbs = {
join: 'joined',
part: 'left',
quit: 'quit'
};
function renderEvent(result, type, events) {
const ending = eventVerbs[type];
if (result.length > 1) {
result[result.length - 1].text += ', ';
}
if (events.length === 1) {
result.push(blocks.nick(events[0][0]));
result.push(blocks.text(` ${ending}`));
} else if (events.length === 2) {
result.push(blocks.nick(events[0][0]));
result.push(blocks.text(' and '));
result.push(blocks.nick(events[1][0]));
result.push(blocks.text(` ${ending}`));
} else if (events.length > 2) {
result.push(blocks.nick(events[0][0]));
result.push(blocks.text(', '));
result.push(blocks.nick(events[1][0]));
result.push(blocks.text(' and '));
result.push(blocks.events(events.length - 2));
result.push(blocks.text(` ${ending}`));
}
}
function renderEvents(events) {
const first = events[0];
if (first.type === 'kick') {
const [kicked, by] = first.params;
return [blocks.nick(by), blocks.text(' kicked '), blocks.nick(kicked)];
}
if (first.type === 'topic') {
const [nick, topic] = first.params;
if (!topic) {
return [blocks.nick(nick), blocks.text(' cleared the topic')];
}
return [
blocks.nick(nick),
blocks.text(' changed the topic to: '),
...colorify(linkify(topic))
];
}
const byType = {};
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
const [nick] = event.params;
if (!byType[event.type]) {
byType[event.type] = [event.params];
} else if (byType[event.type].indexOf(nick) === -1) {
byType[event.type].push(event.params);
}
}
const result = [];
if (byType.join) {
renderEvent(result, 'join', byType.join);
}
if (byType.part) {
renderEvent(result, 'part', byType.part);
}
if (byType.quit) {
renderEvent(result, 'quit', byType.quit);
}
if (byType.nick) {
if (result.length > 1) {
result[result.length - 1].text += ', ';
}
const [oldNick, newNick] = byType.nick[0];
result.push(blocks.nick(oldNick));
result.push(blocks.text(' changed nick to '));
result.push(blocks.nick(newNick));
if (byType.nick.length > 1) {
result.push(blocks.text(' and '));
result.push(blocks.events(byType.nick.length - 1));
result.push(blocks.text(' changed nick'));
}
}
return result;
}
let nextID = 0;
function initMessage(
state,
message,
network,
tab,
wrapWidth,
charWidth,
windowWidth,
prepend
) {
const messages = state[network][tab];
if (messages.length > 0 && !prepend) {
const lastMessage = messages[messages.length - 1];
if (shouldCollapse(lastMessage, message)) {
lastMessage.events.push(message.events[0]);
lastMessage.content = renderEvents(lastMessage.events);
[lastMessage.breakpoints, lastMessage.length] = findBreakpoints(
lastMessage.content
);
lastMessage.height = messageHeight(
lastMessage,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
return false;
}
}
if (message.time) {
message.date = new Date(message.time * 1000);
} else {
message.date = new Date();
}
message.time = timestamp(message.date);
if (!message.id) {
message.id = nextID;
nextID++;
}
if (tab.charAt(0) === '#') {
message.channel = true;
}
if (message.events) {
message.type = 'info';
message.content = renderEvents(message.events);
} else {
message.content = message.content || '';
// Collapse multiple adjacent spaces into a single one
message.content = message.content.replace(/\s\s+/g, ' ');
if (message.content.indexOf('\x01ACTION') === 0) {
const { from } = message;
message.from = null;
message.type = 'action';
message.content = from + message.content.slice(7, -1);
}
}
if (!message.events) {
message.content = colorify(linkify(message.content));
}
[message.breakpoints, message.length] = findBreakpoints(message.content);
message.height = messageHeight(
message,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
message.indent = 6 * charWidth;
return true;
}
function createDateMessage(date) {
const message = {
id: nextID,
type: 'date',
content: formatDate(date),
height: 40
};
nextID++;
return message;
}
function isSameDay(d1, d2) {
return (
d1.getDate() === d2.getDate() &&
d1.getMonth() === d2.getMonth() &&
d1.getFullYear() === d2.getFullYear()
);
}
function reducerPrependMessages(
state,
messages,
network,
tab,
wrapWidth,
charWidth,
windowWidth
) {
const msgs = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
initMessage(
state,
message,
network,
tab,
wrapWidth,
charWidth,
windowWidth,
true
);
if (i > 0 && !isSameDay(messages[i - 1].date, message.date)) {
msgs.push(createDateMessage(message.date));
}
msgs.push(message);
}
const m = state[network][tab];
if (m.length > 0) {
const lastNewMessage = msgs[msgs.length - 1];
const firstMessage = m[0];
if (
firstMessage.date &&
!isSameDay(firstMessage.date, lastNewMessage.date)
) {
msgs.push(createDateMessage(firstMessage.date));
}
}
m.unshift(...msgs);
}
function reducerAddMessage(message, network, tab, state) {
const messages = state[network][tab];
if (messages.length > 0) {
const lastMessage = messages[messages.length - 1];
if (lastMessage.date && !isSameDay(lastMessage.date, message.date)) {
messages.push(createDateMessage(message.date));
}
}
messages.push(message);
}
export default createReducer(
{},
{
[actions.ADD_MESSAGE](
state,
{ network, tab, message, wrapWidth, charWidth, windowWidth }
) {
init(state, network, tab);
const shouldAdd = initMessage(
state,
message,
network,
tab,
wrapWidth,
charWidth,
windowWidth
);
if (shouldAdd) {
reducerAddMessage(message, network, tab, state);
}
},
[actions.ADD_MESSAGES](
state,
{ network, tab, messages, prepend, wrapWidth, charWidth, windowWidth }
) {
if (prepend) {
init(state, network, tab);
reducerPrependMessages(
state,
messages,
network,
tab,
wrapWidth,
charWidth,
windowWidth
);
} else {
if (!messages[0].tab) {
init(state, network, tab);
}
messages.forEach(message => {
if (message.tab) {
init(state, network, message.tab);
}
const shouldAdd = initMessage(
state,
message,
network,
message.tab || tab,
wrapWidth,
charWidth,
windowWidth
);
if (shouldAdd) {
reducerAddMessage(message, network, message.tab || tab, state);
}
});
}
},
[actions.DISCONNECT](state, { network }) {
delete state[network];
},
[actions.PART](state, { network, channels }) {
channels.forEach(channel => delete state[network][channel]);
},
[actions.CLOSE_PRIVATE_CHAT](state, { network, nick }) {
delete state[network][nick];
},
[actions.socket.CHANNEL_FORWARD](state, { network, old }) {
if (state[network]) {
delete state[network][old];
}
},
[actions.UPDATE_MESSAGE_HEIGHT](
state,
{ wrapWidth, charWidth, windowWidth }
) {
Object.keys(state).forEach(network =>
Object.keys(state[network]).forEach(target =>
state[network][target].forEach(message => {
if (message.type === 'date') {
return;
}
message.height = messageHeight(
message,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
})
)
);
},
[actions.INIT](state, { networks }) {
initNetworks(state, networks);
},
[actions.socket.NETWORKS](state, { data }) {
initNetworks(state, data);
}
}
);
export function getMessageTab(network, to) {
if (!to || to === '*' || (!isChannel(to) && to.indexOf('.') !== -1)) {
return network;
}
return to;
}
export function fetchMessages() {
return (dispatch, getState) => {
const state = getState();
const first = getSelectedMessages(state)[0];
if (!first) {
return;
}
const tab = state.tab.selected;
if (tab.name) {
dispatch({
type: actions.FETCH_MESSAGES,
socket: {
type: 'fetch_messages',
data: {
network: tab.network,
channel: tab.name,
next: first.id
}
}
});
}
};
}
export function addFetchedMessages(network, tab) {
return {
type: actions.ADD_FETCHED_MESSAGES,
network,
tab
};
}
export function updateMessageHeight(wrapWidth, charWidth, windowWidth) {
return {
type: actions.UPDATE_MESSAGE_HEIGHT,
wrapWidth,
charWidth,
windowWidth
};
}
export function sendMessage(content, to, network) {
return (dispatch, getState) => {
const state = getState();
const { wrapWidth, charWidth, windowWidth } = getApp(state);
dispatch({
type: actions.ADD_MESSAGE,
network,
tab: to,
message: {
from: state.networks[network].nick,
content
},
wrapWidth,
charWidth,
windowWidth,
socket: {
type: 'message',
data: { content, to, network }
}
});
};
}
export function addMessage(message, network, to) {
const tab = getMessageTab(network, to);
return (dispatch, getState) => {
const { wrapWidth, charWidth, windowWidth } = getApp(getState());
dispatch({
type: actions.ADD_MESSAGE,
network,
tab,
message,
wrapWidth,
charWidth,
windowWidth
});
};
}
export function addMessages(messages, network, to, prepend, next) {
const tab = getMessageTab(network, to);
return (dispatch, getState) => {
const state = getState();
if (next) {
messages[0].id = next;
messages[0].next = true;
}
const { wrapWidth, charWidth, windowWidth } = getApp(state);
dispatch({
type: actions.ADD_MESSAGES,
network,
tab,
messages,
prepend,
wrapWidth,
charWidth,
windowWidth
});
};
}
export function addEvent(network, tab, type, ...params) {
return addMessage(
{
type: 'info',
events: [
{
type,
params,
time: unix()
}
]
},
network,
tab
);
}
export function broadcastEvent(network, channels, type, ...params) {
const now = unix();
return addMessages(
channels.map(channel => ({
type: 'info',
tab: channel,
events: [
{
type,
params,
time: now
}
]
})),
network
);
}
export function broadcast(message, network, channels) {
return addMessages(
channels.map(channel => ({
tab: channel,
content: message,
type: 'info'
})),
network
);
}
export function print(message, network, channel, type) {
if (Array.isArray(message)) {
return addMessages(
message.map(line => ({
content: line,
type
})),
network,
channel
);
}
return addMessage(
{
content: message,
type
},
network,
channel
);
}
export function inform(message, network, channel) {
return print(message, network, channel, 'info');
}
export function runCommand(command, channel, network) {
return {
type: actions.COMMAND,
command,
channel,
network
};
}
export function raw(message, network) {
return {
type: actions.RAW,
message,
network,
socket: {
type: 'raw',
data: { message, network }
}
};
}

View File

@ -1,47 +0,0 @@
import { createSelector } from 'reselect';
import createReducer from 'utils/createReducer';
import * as actions from './actions';
export const getModals = state => state.modals;
export const getHasOpenModals = createSelector(getModals, modals => {
const keys = Object.keys(modals);
for (let i = 0; i < keys.length; i++) {
if (modals[keys[i]].isOpen) {
return true;
}
}
return false;
});
export default createReducer(
{},
{
[actions.OPEN_MODAL](state, { name, payload = {} }) {
state[name] = {
isOpen: true,
payload
};
},
[actions.CLOSE_MODAL](state, { name }) {
state[name].isOpen = false;
}
}
);
export function openModal(name, payload) {
return {
type: actions.OPEN_MODAL,
name,
payload
};
}
export function closeModal(name) {
return {
type: actions.CLOSE_MODAL,
name
};
}

View File

@ -1,229 +0,0 @@
import { createSelector } from 'reselect';
import get from 'lodash/get';
import createReducer from 'utils/createReducer';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
export const getNetworks = state => state.networks;
export const getCurrentNick = createSelector(
getNetworks,
getSelectedTab,
(networks, tab) => {
if (!networks[tab.network]) {
return;
}
const { editedNick } = networks[tab.network];
if (editedNick === null) {
return networks[tab.network].nick;
}
return editedNick;
}
);
export const getCurrentNetworkName = createSelector(
getNetworks,
getSelectedTab,
(networks, tab) => get(networks, [tab.network, 'name'])
);
export const getCurrentNetworkError = createSelector(
getNetworks,
getSelectedTab,
(networks, tab) => get(networks, [tab.network, 'error'], null)
);
export default createReducer(
{},
{
[actions.CONNECT](state, { host, nick, name }) {
if (!state[host]) {
state[host] = {
nick,
editedNick: null,
name: name || host,
connected: false,
error: null,
features: {}
};
}
},
[actions.DISCONNECT](state, { network }) {
delete state[network];
},
[actions.SET_NETWORK_NAME](state, { network, name }) {
state[network].name = name;
},
[actions.SET_NICK](state, { network, nick, editing }) {
if (editing) {
state[network].editedNick = nick;
} else if (nick === '') {
state[network].editedNick = null;
}
},
[actions.socket.NICK](state, { network, oldNick, newNick }) {
if (!oldNick || oldNick === state[network].nick) {
state[network].nick = newNick;
state[network].editedNick = null;
}
},
[actions.socket.NICK_FAIL](state, { network }) {
state[network].editedNick = null;
},
[actions.INIT](state, { networks }) {
if (networks) {
networks.forEach(
({ host, name = host, nick, connected, error, features = {} }) => {
state[host] = {
name,
nick,
connected,
error,
features,
editedNick: null
};
}
);
}
},
[actions.socket.CONNECTION_UPDATE](state, { network, connected, error }) {
if (state[network]) {
state[network].connected = connected;
state[network].error = error;
}
},
[actions.socket.FEATURES](state, { network, features }) {
const srv = state[network];
if (srv) {
srv.features = features;
if (features.NETWORK && srv.name === network) {
srv.name = features.NETWORK;
}
}
}
}
);
export function connect(config) {
return {
type: actions.CONNECT,
...config,
socket: {
type: 'connect',
data: config
}
};
}
export function disconnect(network) {
return dispatch => {
dispatch({
type: actions.DISCONNECT,
network,
socket: {
type: 'quit',
data: { network }
}
});
dispatch(updateSelection());
};
}
export function reconnect(network, settings) {
return {
type: actions.RECONNECT,
network,
settings,
socket: {
type: 'reconnect',
data: {
...settings,
network
}
}
};
}
export function whois(user, network) {
return {
type: actions.WHOIS,
user,
network,
socket: {
type: 'whois',
data: { user, network }
}
};
}
export function away(message, network) {
return {
type: actions.AWAY,
message,
network,
socket: {
type: 'away',
data: { message, network }
}
};
}
export function setNick(nick, network, editing) {
nick = nick.trim().replace(' ', '');
const action = {
type: actions.SET_NICK,
nick,
network,
editing
};
if (!editing && nick !== '') {
action.socket = {
type: 'nick',
data: {
newNick: nick,
network
}
};
}
return action;
}
export function isValidNetworkName(name) {
return name.trim() !== '';
}
export function setNetworkName(name, network) {
const action = {
type: actions.SET_NETWORK_NAME,
name,
network
};
if (isValidNetworkName(name)) {
action.socket = {
type: 'set_network_name',
data: {
name,
network
},
debounce: {
delay: 500,
key: `network_name:${network}`
}
};
}
return action;
}

View File

@ -1,86 +0,0 @@
import sortBy from 'lodash/sortBy';
import { isDM } from 'utils';
import createReducer from 'utils/createReducer';
import { updateSelection } from './tab';
import * as actions from './actions';
export const getPrivateChats = state => state.privateChats;
function open(state, network, nick) {
if (!state[network]) {
state[network] = [];
}
if (!state[network].includes(nick)) {
state[network].push(nick);
state[network] = sortBy(state[network], v => v.toLowerCase());
}
}
export default createReducer(
{},
{
[actions.OPEN_PRIVATE_CHAT](state, action) {
open(state, action.network, action.nick);
},
[actions.CLOSE_PRIVATE_CHAT](state, { network, nick }) {
const i = state[network]?.findIndex(n => n === nick);
if (i !== -1) {
state[network].splice(i, 1);
}
},
[actions.INIT](state, { openDMs }) {
if (openDMs) {
openDMs.forEach(({ network, name }) => {
if (!state[network]) {
state[network] = [];
}
state[network].push(name);
});
}
},
[actions.ADD_MESSAGE](state, { message }) {
if (isDM(message)) {
open(state, message.network, message.from);
}
},
[actions.DISCONNECT](state, { network }) {
delete state[network];
}
}
);
export function openPrivateChat(network, nick) {
return (dispatch, getState) => {
if (!getState().privateChats[network]?.includes(nick)) {
dispatch({
type: actions.OPEN_PRIVATE_CHAT,
network,
nick,
socket: {
type: 'open_dm',
data: { network, name: nick }
}
});
}
};
}
export function closePrivateChat(network, nick) {
return dispatch => {
dispatch({
type: actions.CLOSE_PRIVATE_CHAT,
network,
nick,
socket: {
type: 'close_dm',
data: { network, name: nick }
}
});
dispatch(updateSelection());
};
}

View File

@ -1,38 +0,0 @@
import createReducer from 'utils/createReducer';
import * as actions from './actions';
const initialState = {
show: false,
results: []
};
export const getSearch = state => state.search;
export default createReducer(initialState, {
[actions.socket.SEARCH](state, { results }) {
state.results = results || [];
},
[actions.TOGGLE_SEARCH](state) {
state.show = !state.show;
}
});
export function searchMessages(network, channel, phrase) {
return {
type: actions.SEARCH_MESSAGES,
network,
channel,
phrase,
socket: {
type: 'search',
data: { network, channel, phrase }
}
};
}
export function toggleSearch() {
return {
type: actions.TOGGLE_SEARCH
};
}

View File

@ -1,130 +0,0 @@
import createReducer from 'utils/createReducer';
import * as actions from './actions';
export const getSettings = state => state.settings;
export default createReducer(
{},
{
[actions.UPLOAD_CERT](state) {
state.uploadingCert = true;
},
[actions.socket.CERT_SUCCESS](state) {
state.uploadingCert = false;
delete state.certFile;
delete state.cert;
delete state.keyFile;
delete state.key;
},
[actions.socket.CERT_FAIL](state, action) {
state.uploadingCert = false;
state.certError = action.message;
},
[actions.SET_CERT_ERROR](state, action) {
state.uploadingCert = false;
state.certError = action.message;
},
[actions.SET_CERT](state, action) {
state.certFile = action.fileName;
state.cert = action.cert;
},
[actions.SET_KEY](state, action) {
state.keyFile = action.fileName;
state.key = action.key;
},
[actions.SETTINGS_SET](state, { key, value, settings }) {
if (settings) {
Object.assign(state, settings);
} else {
state[key] = value;
}
},
[actions.INIT](state, { settings }) {
return settings;
}
}
);
export function setCertError(message) {
return {
type: actions.SET_CERT_ERROR,
message
};
}
export function uploadCert() {
return (dispatch, getState) => {
const { settings } = getState();
if (settings.cert && settings.key) {
dispatch({
type: actions.UPLOAD_CERT,
socket: {
type: 'cert',
data: {
cert: settings.cert,
key: settings.key
}
}
});
} else {
dispatch(setCertError('Missing certificate or key'));
}
};
}
export function setCert(fileName, cert) {
return {
type: actions.SET_CERT,
fileName,
cert
};
}
export function setKey(fileName, key) {
return {
type: actions.SET_KEY,
fileName,
key
};
}
export function setSetting(key, value) {
return {
type: actions.SETTINGS_SET,
key,
value,
socket: {
type: 'settings_set',
data: {
[key]: value
},
debounce: {
delay: 250,
key: `settings:${key}`
}
}
};
}
export function setSettings(settings, local = false) {
const action = {
type: actions.SETTINGS_SET,
settings
};
if (!local) {
action.socket = {
type: 'settings_set',
data: settings
};
}
return action;
}

View File

@ -1,133 +0,0 @@
import get from 'lodash/get';
import Cookie from 'js-cookie';
import createReducer from 'utils/createReducer';
import { push, replace, LOCATION_CHANGED } from 'utils/router';
import * as actions from './actions';
import { find } from '../utils';
const initialState = {
selected: {},
history: []
};
function selectTab(state, action) {
state.selected = {
network: action.network,
name: action.name
};
state.history.push(state.selected);
}
export const getSelectedTab = state => state.tab.selected;
export default createReducer(initialState, {
[actions.SELECT_TAB]: selectTab,
[actions.JOIN](state, { network, channels, selectFirst }) {
if (selectFirst) {
state.selected = {
network,
name: channels[0]
};
state.history.push(state.selected);
}
},
[actions.PART](state, action) {
state.history = state.history.filter(
tab =>
!(tab.network === action.network && tab.name === action.channels[0])
);
},
[actions.CLOSE_PRIVATE_CHAT](state, action) {
state.history = state.history.filter(
tab => !(tab.network === action.network && tab.name === action.nick)
);
},
[actions.DISCONNECT](state, action) {
state.history = state.history.filter(tab => tab.network !== action.network);
},
[LOCATION_CHANGED](state, action) {
const { route, params } = action;
if (route === 'chat') {
selectTab(state, params);
} else {
state.selected = {};
}
}
});
export function select(network, name, doReplace) {
const navigate = doReplace ? replace : push;
if (name) {
return navigate(`/${network}/${encodeURIComponent(name)}`);
}
return navigate(`/${network}`);
}
export function tabExists(
{ network, name },
{ networks, channels, privateChats }
) {
return (
(name && get(channels, [network, name])) ||
(!name && network && networks[network]) ||
(name && find(privateChats[network], nick => nick === name))
);
}
function parseTabCookie() {
const cookie = Cookie.get('tab');
if (cookie) {
const [network, name = null] = cookie.split(/;(.+)/);
return { network, name };
}
return null;
}
export function updateSelection(tryCookie) {
return (dispatch, getState) => {
const state = getState();
if (tabExists(state.tab.selected, state)) {
return;
}
if (tryCookie) {
const tab = parseTabCookie();
if (tab && tabExists(tab, state)) {
return dispatch(select(tab.network, tab.name, true));
}
}
const { networks } = state;
const { history } = state.tab;
const { network } = state.tab.selected;
const networkAddrs = Object.keys(networks);
if (networkAddrs.length === 0) {
dispatch(replace('/connect'));
} else if (
history.length > 0 &&
tabExists(history[history.length - 1], state)
) {
const tab = history[history.length - 1];
dispatch(select(tab.network, tab.name, true));
} else if (networks[network]) {
dispatch(select(network, null, true));
} else {
dispatch(select(networkAddrs.sort()[0], null, true));
}
};
}
export function setSelectedTab(network, name = null) {
return {
type: actions.SELECT_TAB,
network,
name
};
}

View File

@ -1,30 +0,0 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import createReducer from 'state';
import { routeReducer, routeMiddleware } from 'utils/router';
import message from './middleware/message';
import createSocketMiddleware from './middleware/socket';
import commands from './commands';
export default function configureStore(socket) {
/* eslint-disable no-underscore-dangle */
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const reducer = createReducer(routeReducer);
const store = createStore(
reducer,
composeEnhancers(
applyMiddleware(
thunk,
routeMiddleware,
createSocketMiddleware(socket),
message,
commands
)
)
);
return store;
}

View File

@ -1,17 +0,0 @@
import { skipWaiting, clientsClaim } from 'workbox-core';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { NavigationRoute, registerRoute } from 'workbox-routing';
skipWaiting();
clientsClaim();
precacheAndRoute(self.__WB_MANIFEST, {
ignoreUrlParametersMatching: [/.*/]
});
const handler = createHandlerBoundToURL('/');
registerRoute(
new NavigationRoute(handler, {
denylist: [new RegExp('/downloads/')]
})
);

View File

@ -1,140 +0,0 @@
import {
trimPrefixChar,
isChannel,
isValidNick,
isValidChannel,
isValidUsername
} from '..';
import linkify from '../linkify';
describe('trimPrefixChar()', () => {
it('trims prefix characters', () => {
expect(trimPrefixChar('##chan', '#')).toBe('chan');
expect(trimPrefixChar('#chan', '#')).toBe('chan');
expect(trimPrefixChar('chan', '#')).toBe('chan');
});
});
describe('isChannel()', () => {
it('it handles strings', () => {
expect(isChannel('#cake')).toBe(true);
expect(isChannel('cake')).toBe(false);
});
it('handles tab objects', () => {
expect(isChannel({ name: '#cake' })).toBe(true);
expect(isChannel({ name: 'cake' })).toBe(false);
});
});
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 => ({
type: 'link',
url: proto(href),
text: href
});
const buildText = arr => {
for (let i = 0; i < arr.length; i++) {
if (typeof arr[i] === 'string') {
arr[i] = {
type: 'text',
text: arr[i]
};
}
}
return arr;
};
it('returns a text block when no matches are found', () =>
['just some text', ''].forEach(input =>
expect(linkify(input)).toStrictEqual([{ type: 'text', text: input }])
));
it('linkifies text', () =>
Object.entries({
'google.com': [linkTo('google.com')],
'google.com stuff': [linkTo('google.com'), ' stuff'],
'cake google.com stuff': ['cake ', linkTo('google.com'), ' stuff'],
'cake google.com stuff https://google.com': [
'cake ',
linkTo('google.com'),
' stuff ',
linkTo('https://google.com')
],
'cake google.com stuff pie https://google.com ': [
'cake ',
linkTo('google.com'),
' stuff pie ',
linkTo('https://google.com'),
' '
],
' google.com': [' ', linkTo('google.com')],
'google.com ': [linkTo('google.com'), ' '],
'/google.com?': ['/', linkTo('google.com'), '?']
}).forEach(([input, expected]) =>
expect(linkify(input)).toEqual(buildText(expected))
));
});

View File

@ -1,22 +0,0 @@
import { hsluvToHex } from 'hsluv';
import fnv1a from '@sindresorhus/fnv1a';
const colors = [];
for (let i = 0; i < 72; i++) {
colors[i] = hsluvToHex([i * 5, 40, 50]);
colors[i + 72] = hsluvToHex([i * 5, 70, 50]);
colors[i + 144] = hsluvToHex([i * 5, 100, 50]);
}
const cache = {};
export default function stringToRGB(str) {
if (cache[str]) {
return cache[str];
}
const color = colors[fnv1a(str) % colors.length];
cache[str] = color;
return color;
}

View File

@ -1,356 +0,0 @@
export const formatChars = {
bold: 0x02,
italic: 0x1d,
underline: 0x1f,
strikethrough: 0x1e,
color: 0x03,
reverseColor: 0x16,
reset: 0x0f
};
export const colors = {
0: 'white',
1: 'black',
2: 'blue',
3: 'green',
4: 'red',
5: 'brown',
6: 'magenta',
7: 'orange',
8: 'yellow',
9: 'lightgreen',
10: 'cyan',
11: 'lightcyan',
12: 'lightblue',
13: 'pink',
14: 'gray',
15: 'lightgray',
16: '#470000',
17: '#472100',
18: '#474700',
19: '#324700',
20: '#004700',
21: '#00472c',
22: '#004747',
23: '#002747',
24: '#000047',
25: '#2e0047',
26: '#470047',
27: '#47002a',
28: '#740000',
29: '#743a00',
30: '#747400',
31: '#517400',
32: '#007400',
33: '#007449',
34: '#007474',
35: '#004074',
36: '#000074',
37: '#4b0074',
38: '#740074',
39: '#740045',
40: '#b50000',
41: '#b56300',
42: '#b5b500',
43: '#7db500',
44: '#00b500',
45: '#00b571',
46: '#00b5b5',
47: '#0063b5',
48: '#0000b5',
49: '#7500b5',
50: '#b500b5',
51: '#b5006b',
52: '#ff0000',
53: '#ff8c00',
54: '#ffff00',
55: '#b2ff00',
56: '#00ff00',
57: '#00ffa0',
58: '#00ffff',
59: '#008cff',
60: '#0000ff',
61: '#a500ff',
62: '#ff00ff',
63: '#ff0098',
64: '#ff5959',
65: '#ffb459',
66: '#ffff71',
67: '#cfff60',
68: '#6fff6f',
69: '#65ffc9',
70: '#6dffff',
71: '#59b4ff',
72: '#5959ff',
73: '#c459ff',
74: '#ff66ff',
75: '#ff59bc',
76: '#ff9c9c',
77: '#ffd39c',
78: '#ffff9c',
79: '#e2ff9c',
80: '#9cff9c',
81: '#9cffdb',
82: '#9cffff',
83: '#9cd3ff',
84: '#9c9cff',
85: '#dc9cff',
86: '#ff9cff',
87: '#ff94d3',
88: '#000000',
89: '#131313',
90: '#282828',
91: '#363636',
92: '#4d4d4d',
93: '#656565',
94: '#818181',
95: '#9f9f9f',
96: '#bcbcbc',
97: '#e2e2e2',
98: '#ffffff'
};
function tokenize(str) {
const tokens = [];
let colorBuffer = '';
let color = false;
let background = false;
let colorToken;
let start = 0;
let end = 0;
const pushText = () => {
if (end > start) {
tokens.push({
type: 'text',
content: str.slice(start, end)
});
start = end;
}
};
const pushToken = token => {
pushText();
tokens.push(token);
};
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
if (color) {
if (charCode >= 48 && charCode <= 57 && colorBuffer.length < 2) {
colorBuffer += str[i];
} else if (charCode === 44 && !background) {
colorToken.color = colors[parseInt(colorBuffer, 10)];
colorBuffer = '';
background = true;
} else {
if (background) {
if (colorBuffer.length > 0) {
colorToken.background = colors[parseInt(colorBuffer, 10)];
} else {
// Trailing comma
start--;
}
} else {
colorToken.color = colors[parseInt(colorBuffer, 10)];
}
start--;
colorBuffer = '';
color = false;
tokens.push(colorToken);
}
} else {
switch (charCode) {
case formatChars.bold:
pushToken({
type: 'bold'
});
break;
case formatChars.italic:
pushToken({
type: 'italic'
});
break;
case formatChars.underline:
pushToken({
type: 'underline'
});
break;
case formatChars.strikethrough:
pushToken({
type: 'strikethrough'
});
break;
case formatChars.color:
pushText();
colorToken = {
type: 'color'
};
color = true;
background = false;
break;
case formatChars.reverseColor:
pushToken({
type: 'reverse'
});
break;
case formatChars.reset:
pushToken({
type: 'reset'
});
break;
default:
start--;
}
}
start++;
end++;
}
if (start === 0) {
return str;
}
pushText();
return tokens;
}
function colorifyString(str, state = {}) {
const tokens = tokenize(str);
if (tokens === str) {
return [tokens, state];
}
const result = [];
let style = state.style || {};
let reverse = state.reverse || false;
const toggle = (prop, value, multiple) => {
if (style[prop]) {
if (multiple) {
const props = style[prop].split(' ');
const i = props.indexOf(value);
if (i !== -1) {
props.splice(i, 1);
} else {
props.push(value);
}
style[prop] = props.join(' ');
} else {
delete style[prop];
}
} else {
style[prop] = value;
}
};
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
switch (token.type) {
case 'bold':
toggle('fontWeight', 700);
break;
case 'italic':
toggle('fontStyle', 'italic');
break;
case 'underline':
toggle('textDecoration', 'underline', true);
break;
case 'strikethrough':
toggle('textDecoration', 'line-through', true);
break;
case 'color':
if (!token.color) {
delete style.color;
delete style.background;
} else if (reverse) {
style.color = token.background;
style.background = token.color;
} else {
style.color = token.color;
style.background = token.background;
}
break;
case 'reverse':
reverse = !reverse;
if (style.color) {
const bg = style.background;
style.background = style.color;
style.color = bg;
}
break;
case 'reset':
style = {};
break;
case 'text':
if (Object.keys(style).length > 0) {
result.push({
type: 'format',
style,
text: token.content
});
style = { ...style };
} else {
result.push({
type: 'text',
text: token.content
});
}
break;
default:
}
}
return [result, { style, reverse }];
}
export default function colorify(blocks) {
if (!blocks) {
return blocks;
}
const result = [];
let colored;
let state;
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
if (block.type === 'text') {
[colored, state] = colorifyString(block.text, state);
if (colored !== block.text) {
result.push(...colored);
} else {
result.push(block);
}
} else {
result.push(block);
}
}
return result;
}

View File

@ -1,8 +0,0 @@
import { connect } from 'react-redux';
const strictEqual = (a, b) => a === b;
export default (mapState, mapDispatch) =>
connect(mapState, mapDispatch, null, {
areStatePropsEqual: strictEqual
});

View File

@ -1,11 +0,0 @@
import produce from 'immer';
import has from 'lodash/has';
export default function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if (has(handlers, action.type)) {
return produce(state, draft => handlers[action.type](draft, action));
}
return state;
};
}

View File

@ -1,226 +0,0 @@
import padStart from 'lodash/padStart';
export { findBreakpoints, messageHeight } from './messageHeight';
export { default as linkify } from './linkify';
export function isChannel(name) {
// TODO: Handle other channel types
if (typeof name === 'object') {
({ name } = name);
}
return typeof name === 'string' && name[0] === '#';
}
export function stringifyTab(network, name) {
if (typeof network === 'object') {
if (network.name) {
return `${network.network};${network.name}`;
}
return network.network;
}
if (name) {
return `${network};${name}`;
}
return network;
}
function isString(s, maxLength) {
if (!s || typeof s !== 'string') {
return false;
}
if (maxLength && s.length > maxLength) {
return false;
}
return true;
}
export function isDM({ from, to }) {
return !to && from?.indexOf('.') === -1 && !isChannel(from);
}
export function trimPrefixChar(str, char) {
if (!isString(str)) {
return str;
}
let start = 0;
while (str[start] === char) {
start++;
}
if (start > 0) {
return str.slice(start);
}
return str;
}
// 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;
}
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 isInt(i, min, max) {
if (typeof i === 'string') {
i = parseInt(i, 10);
}
if (i < min || i > max || Math.floor(i) !== i) {
return false;
}
return true;
}
export function timestamp(date = new Date()) {
const h = padStart(date.getHours(), 2, '0');
const m = padStart(date.getMinutes(), 2, '0');
return `${h}:${m}`;
}
const dateFmt = new Intl.DateTimeFormat(window.navigator.language);
export const formatDate = dateFmt.format;
export function unix(date) {
if (date) {
return Math.floor(date.getTime() / 1000);
}
return Math.floor(Date.now() / 1000);
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
export function stringWidth(str, font) {
ctx.font = font;
return ctx.measureText(str).width;
}
export function measureScrollBarWidth() {
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.width = '100px';
document.body.appendChild(outer);
const widthNoScroll = outer.offsetWidth;
outer.style.overflow = 'scroll';
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
return widthNoScroll - widthWithScroll;
}
export function findIndex(arr, pred) {
if (!Array.isArray(arr) || typeof pred !== 'function') {
return -1;
}
for (let i = 0; i < arr.length; i++) {
if (pred(arr[i])) {
return i;
}
}
return -1;
}
export function find(arr, pred) {
const i = findIndex(arr, pred);
if (i !== -1) {
return arr[i];
}
return null;
}
export function count(arr, pred) {
if (!Array.isArray(arr) || typeof pred !== 'function') {
return 0;
}
let c = 0;
for (let i = 0; i < arr.length; i++) {
if (pred(arr[i])) {
c++;
}
}
return c;
}

View File

@ -1,76 +0,0 @@
import Autolinker from 'autolinker';
const autolinker = new Autolinker({
stripPrefix: false,
stripTrailingSlash: false
});
function pushText(arr, text) {
const last = arr[arr.length - 1];
if (last?.type === 'text') {
last.text += text;
} else {
arr.push({
type: 'text',
text
});
}
}
function pushLink(arr, url, text) {
arr.push({
type: 'link',
url,
text
});
}
export default function linkify(text) {
if (typeof text !== 'string') {
return text;
}
let matches = autolinker.parseText(text);
if (matches.length === 0) {
return [
{
type: 'text',
text
}
];
}
const result = [];
let pos = 0;
matches = autolinker.compactMatches(matches);
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
if (match.getType() === 'url') {
if (match.offset > pos) {
pushText(result, text.slice(pos, match.offset));
}
pushLink(result, match.getAnchorHref(), match.matchedText);
} else {
pushText(
result,
text.slice(pos, match.offset + match.matchedText.length)
);
}
pos = match.offset + match.matchedText.length;
}
if (pos < text.length) {
if (result[result.length - 1]?.type === 'text') {
result[result.length - 1].text += text.slice(pos);
} else {
pushText(result, text.slice(pos));
}
}
return result;
}

View File

@ -1,65 +0,0 @@
const lineHeight = 24;
const userListWidth = 200;
const smallScreen = 600;
export function findBreakpoints(blocks) {
const breakpoints = [];
let length = 0;
for (let j = 0; j < blocks.length; j++) {
const {text} = blocks[j];
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
if (char === ' ') {
breakpoints.push({ end: length + i, next: length + i + 1 });
} else if (i !== text.length - 1 && (char === '-' || char === '?')) {
breakpoints.push({ end: length + i + 1, next: length + i + 1 });
}
}
length += text.length;
}
return [breakpoints, length];
}
export function messageHeight(
message,
wrapWidth,
charWidth,
indent = 0,
windowWidth
) {
let pad = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
let height = lineHeight + 8;
if (message.channel && windowWidth > smallScreen) {
wrapWidth -= userListWidth;
}
if (pad + message.length * charWidth < wrapWidth) {
return height;
}
const breaks = message.breakpoints;
let prevBreak = 0;
let prevPos = 0;
for (let i = 0; i < breaks.length; i++) {
if (pad + (breaks[i].end - prevBreak) * charWidth >= wrapWidth) {
prevBreak = prevPos;
pad = indent;
height += lineHeight;
}
prevPos = breaks[i].next;
}
if (pad + (message.length - prevBreak) * charWidth >= wrapWidth) {
height += lineHeight;
}
return height;
}

View File

@ -1,46 +0,0 @@
let width;
let height;
const listeners = [];
function update() {
width = window.innerWidth;
height = window.innerHeight;
for (let i = 0; i < listeners.length; i++) {
listeners[i](width, height);
}
}
let resizeRAF;
function resize() {
if (resizeRAF) {
window.cancelAnimationFrame(resizeRAF);
}
resizeRAF = window.requestAnimationFrame(update);
}
update();
window.addEventListener('resize', resize);
export function windowWidth() {
return width;
}
export function windowHeight() {
return height;
}
export function addResizeListener(f, init) {
listeners.push(f);
if (init) {
f(width, height);
}
}
export function removeResizeListener(f) {
const i = listeners.indexOf(f);
if (i > -1) {
listeners.splice(i, 1);
}
}

View File

@ -1,6 +0,0 @@
{
"compilerOptions": {
"baseUrl": "./js"
},
"exclude": ["node_modules"]
}

View File

@ -4,109 +4,64 @@
"description": "", "description": "",
"license": "MIT", "license": "MIT",
"main": "index.js", "main": "index.js",
"browserslist": [
"Edge >= 79",
"Firefox >= 60",
"Chrome >= 61",
"Safari >= 10.1",
"iOS >= 10.3"
],
"devDependencies": { "devDependencies": {
"@babel/core": "^7.10.2", "babel-core": "^6.23.1",
"@babel/plugin-proposal-class-properties": "^7.10.1", "babel-eslint": "^7.1.1",
"@babel/plugin-proposal-export-default-from": "^7.10.1", "babel-jest": "^20.0.0",
"@babel/plugin-proposal-export-namespace-from": "^7.10.1", "babel-loader": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "babel-plugin-module-resolver": "^2.7.1",
"@babel/plugin-transform-react-constant-elements": "^7.10.1", "babel-plugin-rewire": "^1.1.0",
"@babel/plugin-transform-react-inline-elements": "^7.10.1", "babel-plugin-transform-react-constant-elements": "^6.23.0",
"@babel/preset-env": "^7.10.2", "babel-plugin-transform-react-inline-elements": "^6.22.0",
"@babel/preset-react": "^7.10.1", "babel-preset-es2015": "^6.22.0",
"babel-eslint": "^10.1.0", "babel-preset-react": "^6.23.0",
"babel-jest": "^26.0.1", "babel-preset-stage-0": "^6.22.0",
"babel-loader": "^8.1.0",
"brotli": "^1.3.1", "brotli": "^1.3.1",
"canvas": "^2.6.1", "css-loader": "^0.28.0",
"copy-webpack-plugin": "^6.0.2", "eslint": "^3.15.0",
"cross-env": "^7.0.2", "eslint-config-airbnb": "^14.1.0",
"css-loader": "^3.5.3", "eslint-import-resolver-babel-module": "^3.0.0",
"cssnano": "^4.1.10", "eslint-loader": "^1.6.1",
"del": "^5.1.0", "eslint-plugin-import": "^2.2.0",
"eslint": "^7.2.0", "eslint-plugin-jsx-a11y": "^4.0.0",
"eslint-config-airbnb": "^18.2.0", "eslint-plugin-react": "^6.10.0",
"eslint-config-prettier": "^6.11.0", "express": "^4.14.1",
"eslint-import-resolver-webpack": "^0.12.2", "express-http-proxy": "^1.0.1",
"eslint-loader": "^4.0.2", "gulp": "^3.9.1",
"eslint-plugin-babel": "^5.3.0", "gulp-autoprefixer": "^4.0.0",
"eslint-plugin-import": "^2.20.2", "gulp-cached": "^1.1.1",
"eslint-plugin-jsx-a11y": "^6.3.0", "gulp-concat": "^2.6.1",
"eslint-plugin-react": "^7.20.0", "gulp-cssnano": "^2.1.2",
"eslint-plugin-react-hooks": "^4.0.4",
"express": "^4.17.1",
"express-http-proxy": "^1.6.0",
"gulp": "4.0.2",
"gulp-util": "^3.0.8", "gulp-util": "^3.0.8",
"jest": "^26.0.1", "jest": "^20.0.0",
"mini-css-extract-plugin": "^0.9.0", "style-loader": "^0.18.0",
"postcss-flexbugs-fixes": "^4.2.1", "through2": "^2.0.3",
"postcss-loader": "^3.0.0", "webpack": "^3.0.0",
"postcss-preset-env": "^6.7.0", "webpack-dev-middleware": "^1.10.0",
"prettier": "2.0.5", "webpack-hot-middleware": "^2.17.0"
"react-test-renderer": "16.13.1",
"style-loader": "^1.2.1",
"terser-webpack-plugin": "^3.0.6",
"through2": "^3.0.1",
"webpack": "^4.43.0",
"webpack-dev-middleware": "^3.7.2",
"webpack-hot-middleware": "^2.25.0",
"webpack-plugin-hash-output": "^3.2.1",
"workbox-webpack-plugin": "^5.1.3"
}, },
"dependencies": { "dependencies": {
"@sindresorhus/fnv1a": "^1.2.0", "autolinker": "^1.4.3",
"autolinker": "^3.14.1",
"backo": "^1.1.0", "backo": "^1.1.0",
"classnames": "^2.2.6", "base64-arraybuffer": "^0.1.5",
"formik": "^2.1.4", "fontfaceobserver": "^2.0.9",
"history": "^5.0.0-beta.8", "history": "4.5.1",
"hsluv": "^0.1.0", "immutable": "^3.8.1",
"immer": "^7.0.1", "js-cookie": "^2.1.4",
"js-cookie": "^2.2.1", "lodash": "^4.17.4",
"lodash": "^4.17.15", "react": "^15.4.2",
"react": "16.13.1", "react-dom": "^15.4.2",
"react-dom": "16.13.1", "react-hot-loader": "next",
"react-hot-loader": "^4.12.21", "react-redux": "^5.0.2",
"react-icons": "^3.7.0", "react-virtualized": "^9.3.0",
"react-modal": "^3.11.2", "redux": "^3.6.0",
"react-redux": "^7.2.0", "redux-thunk": "^2.2.0",
"react-virtualized-auto-sizer": "^1.0.2", "reselect": "^3.0.0",
"react-window": "^1.8.5", "url-pattern": "^1.0.3"
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"url-pattern": "^1.0.3",
"workbox-core": "^5.1.3",
"workbox-precaching": "^5.1.3",
"workbox-routing": "^5.1.3"
}, },
"scripts": { "scripts": {
"prettier": "prettier --write {.*,*.js,css/*.css,**/*.test.js}",
"prettier:all": "prettier --write {.*,*.js,**/*.js,css/*.css}",
"test": "jest", "test": "jest",
"test:verbose": "jest --verbose", "test:verbose": "jest --verbose",
"test:watch": "jest --watch", "test:watch": "jest --watch"
"gen:install": "cross-env GO111MODULE=off go get -u github.com/andyleap/gencode github.com/mailru/easyjson/...",
"gen:binary": "gencode go -package storage -schema ../storage/storage.schema -unsafe",
"gen:json": "cross-env GO111MODULE=off easyjson -all -lower_camel_case -omit_empty ../server/json.go ../server/index_data.go ../storage/network.go && cross-env GO111MODULE=off easyjson -lower_camel_case -omit_empty ../storage/user.go"
},
"jest": {
"moduleNameMapper": {
"^components(.*)$": "<rootDir>/js/components$1",
"^containers(.*)$": "<rootDir>/js/containers$1",
"^state(.*)$": "<rootDir>/js/state$1",
"^utils(.*)$": "<rootDir>/js/utils$1"
},
"transformIgnorePatterns": [
"node_modules/?!(history)"
]
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@ -1,21 +0,0 @@
{
"short_name": "Dispatch",
"name": "Dispatch",
"icons": [
{
"src": "icon_192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "icon_512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/",
"background_color": "#f0f0f0",
"display": "standalone",
"scope": "/",
"theme_color": "#222"
}

50
client/src/css/fontello.css vendored Normal file
View File

@ -0,0 +1,50 @@
@font-face {
font-family: 'fontello';
src: url('../font/fontello.woff2?48901973') format('woff2'),
url('../font/fontello.woff?48901973') format('woff'),
url('../font/fontello.ttf?48901973') format('truetype');
font-weight: normal;
font-style: normal;
}
[class^="icon-"]:before, [class*=" icon-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: none;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.icon-cancel:before { content: '\e800'; } /* '' */
.icon-menu:before { content: '\e801'; } /* '' */
.icon-cog:before { content: '\e802'; } /* '' */
.icon-search:before { content: '\e803'; } /* '' */
.icon-user:before { content: '\f061'; } /* '' */
.icon-ellipsis:before { content: '\f141'; } /* '' */

41
client/src/css/fonts.css Normal file
View File

@ -0,0 +1,41 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
src: local('Montserrat-Regular'),
url(/font/Montserrat-Regular.woff2) format('woff2'),
url(/font/Montserrat-Regular.woff) format('woff'),
url(/font/Montserrat-Regular.ttf) format('truetype');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
src: local('Montserrat-Bold'),
url(/font/Montserrat-Bold.woff2) format('woff2'),
url(/font/Montserrat-Bold.woff) format('woff'),
url(/font/Montserrat-Bold.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
src: local('Roboto Mono'),
local('RobotoMono-Regular'),
url(/font/RobotoMono-Regular.woff2) format('woff2'),
url(/font/RobotoMono-Regular.woff) format('woff'),
url(/font/RobotoMono-Regular.ttf) format('truetype');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 700;
src: local('Roboto Mono Bold'),
local('RobotoMono-Bold'),
url(/font/RobotoMono-Bold.woff2) format('woff2'),
url(/font/RobotoMono-Bold.woff) format('woff'),
url(/font/RobotoMono-Bold.ttf) format('truetype');
}

684
client/src/css/style.css Normal file
View File

@ -0,0 +1,684 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Roboto Mono, monospace;
background: #f0f0f0;
}
h1, h2, h3, h4, h5, h6 {
font-family: Montserrat, sans-serif;
font-weight: 400;
}
h1 {
font-weight: 700;
}
input {
font: 16px Roboto Mono, monospace;
border: none;
outline: none;
}
button {
font: 16px Montserrat, sans-serif;
border: none;
outline: none;
cursor: pointer;
}
p {
line-height: 1.5;
}
i[class^="icon-"]:before, i[class*=" icon-"]:before {
margin: 0;
}
.success {
color: #6BB758 !important;
}
.error {
color: #F6546A !important;
}
.wrap {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.app-info {
width: 100%;
font-family: Montserrat, sans-serif;
background: #F6546A;
color: #FFF;
height: 50px;
line-height: 50px;
text-align: center;
}
.app-container {
position: relative;
flex: 1;
}
.tablist {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 200px;
background: #222;
color: #FFF;
font-family: Montserrat, sans-serif;
transition: transform .2s;
}
.tab-container {
position: absolute;
top: 50px;
bottom: 50px;
width: 100%;
overflow: auto;
}
.tablist p {
height: 30px;
padding: 3px 15px;
padding-right: 10px;
cursor: pointer;
}
.tablist p:last-child {
margin-bottom: 10px;
}
.tablist p:hover {
background: #111;
}
.tablist p.selected {
padding-left: 10px;
border-left: 5px solid #6BB758;
}
.tab-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-server {
display: flex;
align-items: center;
color: #999;
margin-top: 10px !important;
}
.tab-server .tab-content {
flex: 1;
margin-right: 5px;
}
.tab-label {
margin-top: 10px;
margin-left: 15px;
margin-bottom: 5px;
font-size: 12px;
color: #999;
}
.button-connect {
width: 100%;
height: 50px;
background: #6BB758;
color: #FFF;
}
.button-connect:hover {
background: #7BBF6A;
}
.button-connect:active {
background: #6BB758;
}
.side-buttons {
position: absolute;
bottom: 0;
height: 50px;
width: 200px;
text-align: center;
}
.side-buttons i {
display: inline-block;
color: #999;
width: 50%;
line-height: 50px;
cursor: pointer;
font-size: 20px;
border-top: 1px solid #1D1D1D;
}
.side-buttons i:not(:first-child) {
border-left: 1px solid #1D1D1D;
}
.side-buttons i:hover {
color: #CCC;
background: #1D1D1D;
}
.main-container {
position: absolute;
left: 200px;
top: 0;
bottom: 0;
right: 0;
transition: left .2s, transform .2s;
}
.connect {
display: flex;
justify-content: center;
position: absolute;
top: 0;
width: 100%;
bottom: 0;
overflow: auto;
}
.connect .navicon, .settings .navicon {
position: fixed;
top: 0;
left: 0;
}
.connect-form {
margin: auto 0;
padding-top: 20px;
width: 300px;
}
.connect-form h1 {
margin-bottom: 15px;
text-align: center;
}
.connect-form input {
display: block;
margin: 5px 0px;
padding: 15px;
border: none;
}
.connect-form input[type="submit"],
.connect-form input[type="text"],
.connect-form input[type="password"] {
width: 100%;
}
.connect-form input[type="submit"] {
height: 50px;
margin-bottom: 20px;
font-family: Montserrat, sans-serif;
background: #6BB758;
color: #FFF;
cursor: pointer;
}
.connect-form input[type="submit"]:hover {
background: #7BBF6A;
}
.connect-form input[type="submit"]:active {
background: #6BB758;
}
.connect-form input[type="checkbox"] {
display: inline-block;
margin-right: 5px;
vertical-align: middle;
}
.connect-form i {
float: right;
cursor: pointer;
color: #999;
padding: 10px 5px;
font-size: 24px;
}
.connect-form i:hover {
color: #000;
}
.connect-form label {
display: inline-block;
padding: 10px 0;
color: #333;
}
.chat-title-bar {
font-family: Montserrat, sans-serif;
position: absolute;
left: 0;
top: 0;
right: 0;
height: 50px;
line-height: 50px;
border-bottom: 1px solid #DDD;
display: flex;
font-size: 20px;
}
.chat-channel .chat-title-bar {
right: 200px;
}
.navicon {
display: none;
padding: 0 15px;
line-height: 50px;
font-size: 20px;
cursor: pointer;
}
.chat-title-bar i {
padding: 0 15px;
cursor: pointer;
}
.chat-server .icon-search {
display: none;
}
.chat-server .userlist, .chat-private .userlist {
display: none;
}
.chat-server .userlist-bar, .chat-private .userlist-bar {
display: none;
}
.button-leave {
border-left: 1px solid #DDD;
}
.button-leave:hover {
background: #DDD;
}
.button-userlist {
display: none;
border-left: 1px solid #DDD;
}
.chat-server .button-userlist, .chat-private .button-userlist {
display: none;
}
.chat-title {
margin-left: 15px;
font: 24px Montserrat, sans-serif;
white-space: nowrap;
background: none;
line-height: 50px;
}
.chat-topic-wrap {
flex: 1;
position: relative;
margin: 0 15px;
}
.chat-topic {
position: absolute;
width: 100%;
top: 3px;
font-size: 16px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-topic a {
color: #999;
text-decoration: none;
}
.chat-topic a:hover {
text-decoration: underline;
}
.userlist-bar {
position: absolute;
top: 0;
right: 0;
width: 200px;
height: 50px;
border-left: 1px solid #DDD;
border-bottom: 1px solid #DDD;
line-height: 50px;
text-align: center;
padding: 0 15px;
font-family: Montserrat, sans-serif;
}
.userlist-bar i {
margin-right: 3px;
}
.search {
display: none;
position: absolute;
left: 0;
top: 50px;
bottom: 50px;
right: 200px;
z-index: 3;
background: #f0f0f0;
}
.chat-server .search {
display: none;
}
.chat-private .search {
right: 0;
}
.search-input-wrap {
display: flex;
width: 100%;
background: #FFF;
border-bottom: 1px solid #DDD;
}
.search i {
padding: 15px;
color: #DDD;
}
.search-input {
flex: 1;
padding: 15px;
padding-left: 0;
}
.search-results {
position: absolute;
top: 50px;
bottom: 0;
width: 100%;
overflow: auto;
padding: 10px 15px;
}
.search-result:not(:last-child) {
margin-bottom: 5px;
}
.messagebox {
position: absolute;
left: 0;
top: 50px;
bottom: 50px;
right: 0;
z-index: 1;
overflow: hidden;
}
.chat-channel .messagebox {
right: 200px;
}
.messagebox-top-indicator {
color: #999;
height: 100px;
text-align: center;
padding-top: 40px;
}
.VirtualScroll {
overflow-x: hidden !important;
}
.message {
padding: 4px 15px;
}
.message-info {
color: #999;
}
.message-error {
color: #F6546A;
}
.message-prompt {
font-weight: 700;
font-style: italic;
color: #6BB758;
}
.message-action {
color: #FF6698;
}
.message-time {
font-style: normal;
font-weight: 400;
color: #999;
}
.message-sender {
font-weight: 700;
color: #6BB758;
cursor: pointer;
}
.message a {
text-decoration: none;
color: #0066FF;
}
.message a:hover {
text-decoration: underline;
}
.message-input-wrap {
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 50px;
z-index: 1;
display: flex;
border-top: 1px solid #DDD;
background: #FFF;
}
.message-input-nick {
display: block;
margin: 10px;
line-height: 30px;
height: 30px;
padding: 0 10px;
background: #6BB758 !important;
color: #FFF;
font-family: Montserrat, sans-serif !important;
margin-right: 0;
}
.message-input {
flex: 1;
width: 100%;
height: 100%;
padding: 0 15px;
}
.userlist {
position: absolute;
top: 50px;
bottom: 50px;
right: 0;
width: 200px;
border-left: 1px solid #DDD;
background: #f0f0f0;
z-index: 2;
transition: transform .2s;
}
.userlist p {
padding: 0px 15px;
cursor: pointer;
}
.userlist p:hover {
background: #DDD;
}
.settings {
text-align: center;
}
.settings p {
color: #999;
}
.settings h1 {
margin: 20px;
}
.settings h2 {
margin: 15px;
}
.settings button {
margin: 5px;
color: #FFF;
background: #6BB758;
padding: 10px 20px;
width: 200px;
}
.settings button:hover {
background: #7BBF6A;
}
.settings button:active {
background: #6BB758;
}
.settings div {
display: inline-block;
}
.settings .error {
margin: 10px;
color: #F6546A;
}
.input-file {
color: #FFF;
background: #222 !important;
padding: 10px;
margin: 5px;
width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ReactVirtualized__List {
box-sizing: content-box !important;
outline: none;
}
.rvlist-messages {
padding: 7px 0;
overflow-y: scroll !important;
}
.rvlist-users {
padding: 10px 0;
}
@media (max-width: 600px) {
.app-info {
font-size: 12px;
}
.tablist {
width: 200px;
transform: translateX(-200px);
}
.main-container {
transform: translateX(0);
left: 0;
}
.navicon {
display: inline-block;
}
.main-container.off-canvas {
transform: translateX(200px);
}
.tablist.off-canvas {
transform: translateX(0);
}
.chat-title {
margin-left: 0;
}
.chat-topic {
font-size: 12px;
}
.userlist-bar {
display: none;
}
.userlist {
transform: translateX(200px);
}
.userlist.off-canvas {
transform: translateX(0);
}
.chat-channel .chat-title-bar, .chat-channel .messagebox {
right: 0;
}
.button-userlist {
display: inline-block;
}
.search {
right: 0;
}
.connect-form {
width: 100%;
margin: auto 50px;
max-width: 400px;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More