Compare commits
No commits in common. "d3d83236467e9ea3fbeb7f9a4ca66d773e38f4d2" and "b9b69281112f2793438862a92277b26def221a53" have entirely different histories.
d3d8323646
...
b9b6928111
@ -1,5 +1,3 @@
|
|||||||
dist
|
.git
|
||||||
dispatch
|
|
||||||
client/dist
|
client/dist
|
||||||
client/node_modules
|
client/node_modules
|
||||||
client/yarn-error.log
|
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||||
|
@ -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}}"
|
|
25
.travis.yml
25
.travis.yml
@ -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.*
|
|
||||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
|
||||||
}
|
|
21
Dockerfile
21
Dockerfile
@ -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"]
|
||||||
|
|
||||||
|
87
README.md
87
README.md
@ -1,126 +1,91 @@
|
|||||||
# dispatch [](https://travis-ci.com/khlieng/dispatch)
|
# dispatch [](https://travis-ci.org/khlieng/dispatch)
|
||||||
|
|
||||||
#### [Try it!](https://dispatch.khlieng.com)
|
#### [Try it!](https://dispatch.khlieng.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 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
35
client/.babelrc
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"arrowParens": "avoid"
|
|
||||||
}
|
|
@ -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'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -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');
|
|
||||||
}
|
|
1198
client/css/style.css
1198
client/css/style.css
File diff suppressed because it is too large
Load Diff
@ -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);
|
|
||||||
|
@ -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);
|
|
||||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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];
|
|
||||||
}
|
|
@ -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);
|
|
@ -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);
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
@ -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} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -1,8 +0,0 @@
|
|||||||
import { setConfig } from 'react-hot-loader';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
setConfig({
|
|
||||||
ignoreSFC: !!ReactDOM.setHotElementComparator,
|
|
||||||
pureSFC: true,
|
|
||||||
pureRender: true
|
|
||||||
});
|
|
@ -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))
|
|
||||||
});
|
|
@ -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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
@ -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: []
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
@ -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)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -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);
|
|
||||||
}
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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());
|
|
||||||
};
|
|
||||||
}
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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/')]
|
|
||||||
})
|
|
||||||
);
|
|
@ -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))
|
|
||||||
));
|
|
||||||
});
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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
|
|
||||||
});
|
|
@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "./js"
|
|
||||||
},
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
@ -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 |
@ -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
50
client/src/css/fontello.css
vendored
Normal 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
41
client/src/css/fonts.css
Normal 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
684
client/src/css/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
BIN
client/src/font/Montserrat-Bold.ttf
Normal file
BIN
client/src/font/Montserrat-Bold.ttf
Normal file
Binary file not shown.
BIN
client/src/font/Montserrat-Regular.ttf
Normal file
BIN
client/src/font/Montserrat-Regular.ttf
Normal file
Binary file not shown.
BIN
client/src/font/RobotoMono-Bold.ttf
Normal file
BIN
client/src/font/RobotoMono-Bold.ttf
Normal file
Binary file not shown.
BIN
client/src/font/RobotoMono-Regular.ttf
Normal file
BIN
client/src/font/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user