Compare commits
143 Commits
Author | SHA1 | Date | |
---|---|---|---|
374604fae2 | |||
48f59604a6 | |||
474afda9c2 | |||
a219e689c1 | |||
9c9b05d479 | |||
04852cdf58 | |||
4b4b2394a9 | |||
9b6844449d | |||
9a5d7f8360 | |||
70b2c4df47 | |||
f86e0d9283 | |||
fd6c8a70e2 | |||
ed40b956b7 | |||
ca222ff10d | |||
b2b5f82486 | |||
69d5f41270 | |||
d930365eeb | |||
84c3d5cc88 | |||
ef34baf5a0 | |||
38ed5a367b | |||
4482dd33ce | |||
675e350da3 | |||
7658e3bde7 | |||
7fb0cd3e6a | |||
6c6a9e12cf | |||
ec03db4db6 | |||
afc80650e7 | |||
6146b27adc | |||
973aa3b104 | |||
6f6dfca5fb | |||
b3f2b53a6f | |||
937987da82 | |||
91fd50fe10 | |||
3ec450805b | |||
09de41c0a2 | |||
66bf957460 | |||
ccdd56fea4 | |||
963e36c296 | |||
1de1d1473d | |||
47bc78b80a | |||
628dc66685 | |||
b0b3489e15 | |||
5defd315f1 | |||
2db878d2cb | |||
0b49df6bcf | |||
bd46082cfe | |||
508969e189 | |||
3dea4d6513 | |||
c8c09bce8c | |||
004e7890e5 | |||
f20ca4038e | |||
1406b87d77 | |||
c975c5d120 | |||
e2c6cedc27 | |||
8ed27bf54b | |||
a4d2cf17aa | |||
fb287dce2f | |||
352f7a8487 | |||
ad238726b7 | |||
8f20593b26 | |||
fde6a9e630 | |||
9669092148 | |||
3d2dbd5527 | |||
b07d1eb871 | |||
d2c1297cf7 | |||
f5de115534 | |||
494dbc4cf5 | |||
c49bbc72d4 | |||
e8f5809940 | |||
e0200a2b2a | |||
09248edd59 | |||
24f9553aa5 | |||
121582f72a | |||
70322c2e43 | |||
9529af55c7 | |||
21b6740e8b | |||
09d57b7023 | |||
e7cff1686e | |||
e2a895a1b9 | |||
b4c9338772 | |||
637f0d956b | |||
27653982d7 | |||
2f2c500453 | |||
d27d108a07 | |||
4ac0dd7c4b | |||
b09da1fc3b | |||
2ada552220 | |||
16ce3cdfa5 | |||
9806d6c12f | |||
8f8adc37e7 | |||
ed0c413542 | |||
276d8f7849 | |||
6fd5235ec9 | |||
f502fea5c1 | |||
abc495f849 | |||
4fafe2b158 | |||
29a225ed13 | |||
91e5556c86 | |||
de36fe682a | |||
fb8fec38ff | |||
d4d03eac12 | |||
33e0f67766 | |||
62e115498f | |||
f2504cc245 | |||
735f96d3b1 | |||
f72253966b | |||
b4bdcd4939 | |||
0648b67cb8 | |||
6f0ea05f4b | |||
1b202e7c2b | |||
e132c8201f | |||
4f72e164d7 | |||
7f755d2a83 | |||
8724121552 | |||
0ebd2e5c38 | |||
0941ed8549 | |||
39641c315f | |||
fcd204321a | |||
d08bd43ba0 | |||
60190fbd98 | |||
6ccc57ad64 | |||
94f3777f5f | |||
3d4c1baeda | |||
b176b79144 | |||
0cbbc1b8ff | |||
1ae7d867a9 | |||
87d7337d21 | |||
19bcc51eb4 | |||
20c3855ced | |||
b9b6928111 | |||
d22758227d | |||
a4a4588ae6 | |||
8b44f68231 | |||
c005fc7cae | |||
3f70567d56 | |||
8a62af5a73 | |||
0a96ebb428 | |||
9dffb541b9 | |||
ae6ad0a5b9 | |||
403f7d0942 | |||
8a2fbaca7f | |||
18aff3ded6 | |||
786d8013b9 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
build
|
||||
release
|
||||
dispatch
|
||||
client/dist
|
||||
client/node_modules
|
||||
client/yarn-error.log
|
||||
|
18
.travis.yml
18
.travis.yml
@ -1,8 +1,8 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.8.3
|
||||
- 1.9beta2
|
||||
- "1.10.x"
|
||||
- "1.11.x"
|
||||
- tip
|
||||
|
||||
os:
|
||||
@ -16,15 +16,15 @@ matrix:
|
||||
install:
|
||||
- go get github.com/jteeuwen/go-bindata/...
|
||||
- cd client
|
||||
- nvm install 7.10.0
|
||||
- nvm use 7.10.0
|
||||
- nvm install 10.11.0
|
||||
- nvm use 10.11.0
|
||||
- npm install -g yarn
|
||||
- npm install -g gulp
|
||||
- yarn
|
||||
- yarn global add gulp@next
|
||||
- yarn --ignore-engines
|
||||
|
||||
script:
|
||||
- npm run test:verbose
|
||||
- yarn test:verbose
|
||||
- gulp build
|
||||
- cd ..
|
||||
- go vet $(go list ./... | grep -v '/vendor/')
|
||||
- go test -v -race $(go list ./... | grep -v '/vendor/')
|
||||
- go vet ./...
|
||||
- go test -v -race ./...
|
||||
|
21
Dockerfile
21
Dockerfile
@ -1,7 +1,22 @@
|
||||
FROM scratch
|
||||
# Build
|
||||
FROM golang:alpine AS build
|
||||
|
||||
ADD build/dispatch /
|
||||
ADD ca-certificates.crt /etc/ssl/certs/
|
||||
RUN apk add --update git make build-base && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /go/src/github.com/khlieng/dispatch
|
||||
COPY . /go/src/github.com/khlieng/dispatch
|
||||
RUN go build .
|
||||
|
||||
# Runtime
|
||||
FROM alpine
|
||||
|
||||
RUN apk add --update ca-certificates && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY --from=build /go/src/github.com/khlieng/dispatch/dispatch /dispatch
|
||||
|
||||
EXPOSE 80/tcp
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
|
62
README.md
62
README.md
@ -2,40 +2,46 @@
|
||||
|
||||
#### [Try it!](https://dispatch.khlieng.com)
|
||||
|
||||

|
||||

|
||||
|
||||
### Features
|
||||
* Searchable history
|
||||
* Persistent connections
|
||||
* Multiple servers and users
|
||||
* Automatic HTTPS through Let's Encrypt
|
||||
* Client certificates
|
||||
|
||||
- Searchable history
|
||||
- Persistent connections
|
||||
- Multiple servers and users
|
||||
- Automatic HTTPS through Let's Encrypt
|
||||
- Client certificates
|
||||
|
||||
## Usage
|
||||
|
||||
There is a few different ways of getting it:
|
||||
|
||||
### 1. Binary
|
||||
- **[Windows (x64)](https://github.com/khlieng/dispatch/releases/download/v0.4/dispatch_windows_amd64.zip)**
|
||||
- **[OS X (x64)](https://github.com/khlieng/dispatch/releases/download/v0.4/dispatch_darwin_amd64.zip)**
|
||||
- **[Linux (x64)](https://github.com/khlieng/dispatch/releases/download/v0.4/dispatch_linux_amd64.tar.gz)**
|
||||
|
||||
- **[Windows (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5/dispatch_windows_amd64.zip)**
|
||||
- **[OS X (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5/dispatch_darwin_amd64.zip)**
|
||||
- **[Linux (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5/dispatch_linux_amd64.tar.gz)**
|
||||
- [Other versions](https://github.com/khlieng/dispatch/releases)
|
||||
|
||||
### 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.10 or greater.
|
||||
|
||||
Fetch, compile and run dispatch:
|
||||
|
||||
```bash
|
||||
go get github.com/khlieng/dispatch
|
||||
|
||||
dispatch
|
||||
```
|
||||
|
||||
To get some help run:
|
||||
|
||||
```bash
|
||||
dispatch help
|
||||
```
|
||||
|
||||
### 3. Docker
|
||||
|
||||
```bash
|
||||
docker run -p <http port>:80 -p <https port>:443 -v <path>:/data khlieng/dispatch
|
||||
```
|
||||
@ -43,40 +49,51 @@ docker run -p <http port>:80 -p <https port>:443 -v <path>:/data khlieng/dispatc
|
||||
## Build
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
cd $GOPATH/src/github.com/khlieng/dispatch
|
||||
|
||||
go install
|
||||
```
|
||||
|
||||
### Client
|
||||
This requires [Node.js](https://nodejs.org).
|
||||
|
||||
This requires [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com).
|
||||
|
||||
Fetch the dependencies:
|
||||
|
||||
```bash
|
||||
npm install -g gulp
|
||||
go get github.com/jteeuwen/go-bindata/...
|
||||
yarn global add gulp@next
|
||||
cd $GOPATH/src/github.com/khlieng/dispatch/client
|
||||
npm install
|
||||
yarn
|
||||
```
|
||||
|
||||
Run the build:
|
||||
|
||||
```bash
|
||||
gulp build
|
||||
```
|
||||
|
||||
The server needs to be rebuilt after this.
|
||||
The server needs to be rebuilt to embed new client builds.
|
||||
|
||||
For development with hot reloading start the frontend:
|
||||
|
||||
For development with hot reloading enabled run:
|
||||
```bash
|
||||
gulp
|
||||
```
|
||||
|
||||
And then the backend in a separate terminal:
|
||||
|
||||
```bash
|
||||
dispatch --dev
|
||||
```
|
||||
|
||||
## Libraries
|
||||
|
||||
The libraries this project is built with.
|
||||
|
||||
### Server
|
||||
|
||||
- [Bolt](https://github.com/boltdb/bolt)
|
||||
- [Bleve](https://github.com/blevesearch/bleve)
|
||||
- [Cobra](https://github.com/spf13/cobra)
|
||||
@ -84,8 +101,15 @@ The libraries this project is built with.
|
||||
- [Lego](https://github.com/xenolf/lego)
|
||||
|
||||
### Client
|
||||
|
||||
- [React](https://github.com/facebook/react)
|
||||
- [Redux](https://github.com/reactjs/redux)
|
||||
- [Immutable](https://github.com/facebook/immutable-js)
|
||||
- [React Virtualized](https://github.com/bvaughn/react-virtualized)
|
||||
- [Immer](https://github.com/mweststrate/immer)
|
||||
- [react-window](https://github.com/bvaughn/react-window)
|
||||
- [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
@ -1,35 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
32
client/.babelrc.js
Normal file
32
client/.babelrc.js
Normal file
@ -0,0 +1,32 @@
|
||||
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'
|
||||
],
|
||||
env: {
|
||||
development: {
|
||||
plugins: ['react-hot-loader/babel']
|
||||
},
|
||||
test: {
|
||||
plugins: ['@babel/plugin-transform-modules-commonjs']
|
||||
},
|
||||
production: {
|
||||
plugins: [
|
||||
'@babel/plugin-transform-react-inline-elements',
|
||||
'@babel/plugin-transform-react-constant-elements'
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
@ -1,29 +1,27 @@
|
||||
{
|
||||
"extends": "airbnb",
|
||||
"extends": ["airbnb", "prettier", "prettier/react"],
|
||||
"parser": "babel-eslint",
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"rules": {
|
||||
"arrow-parens": 0,
|
||||
"comma-dangle": [2, "never"],
|
||||
"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,
|
||||
"new-cap": [2, { "capIsNewExceptions": ["Map", "List", "Record", "Set"] }],
|
||||
"no-console": 0,
|
||||
"no-console": 1,
|
||||
"no-param-reassign": 0,
|
||||
"no-plusplus": 0,
|
||||
"no-restricted-globals": 1,
|
||||
"react/destructuring-assignment": 0,
|
||||
"react/jsx-filename-extension": 0,
|
||||
"react/no-array-index-key": 0,
|
||||
"react/prop-types": 0,
|
||||
"react/prefer-stateless-function": 0
|
||||
},
|
||||
"globals": {
|
||||
"DEV": true
|
||||
"react/prop-types": 0
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"babel-module": {}
|
||||
"webpack": {
|
||||
"config": "webpack.config.prod.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
3
client/.prettierrc.json
Normal file
3
client/.prettierrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
@ -1,14 +1,14 @@
|
||||
@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');
|
||||
src: url('/font/fontello.woff2?48901973') format('woff2'),
|
||||
url('/font/fontello.woff?48901973') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
||||
font-family: "fontello";
|
||||
[class^='icon-']:before,
|
||||
[class*=' icon-']:before {
|
||||
font-family: 'fontello';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: none;
|
||||
@ -16,7 +16,7 @@
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: .2em;
|
||||
margin-right: 0.2em;
|
||||
text-align: center;
|
||||
/* opacity: .8; */
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
|
||||
/* Animation center compensation - margins should be symmetric */
|
||||
/* remove if not needed */
|
||||
margin-left: .2em;
|
||||
margin-left: 0.2em;
|
||||
|
||||
/* you can be more comfortable with increased icons size */
|
||||
/* font-size: 120%; */
|
||||
@ -42,9 +42,21 @@
|
||||
/* 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'; } /* '' */
|
||||
.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';
|
||||
} /* '' */
|
35
client/css/fonts.css
Normal file
35
client/css/fonts.css
Normal file
@ -0,0 +1,35 @@
|
||||
@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');
|
||||
}
|
@ -7,38 +7,172 @@
|
||||
body {
|
||||
font-family: Roboto Mono, monospace;
|
||||
background: #f0f0f0;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
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;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: #6bb758;
|
||||
color: #fff;
|
||||
font: 16px Montserrat, sans-serif;
|
||||
border: none;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #7bbf6a;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #6bb758;
|
||||
}
|
||||
|
||||
label {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox.top-label {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
position: absolute;
|
||||
left: -99999px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.checkbox span {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #777;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkbox:not(.top-label) span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.checkbox input:checked + span {
|
||||
background: #6bb758;
|
||||
border-color: #6bb758;
|
||||
}
|
||||
|
||||
.checkbox input:checked + span:before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border-right: 3px solid #fff;
|
||||
border-bottom: 3px solid #fff;
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
transform: rotate(45deg);
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
i[class^='icon-']:before,
|
||||
i[class*=' icon-']:before {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #ddd;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #6bb758 !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f6546a !important;
|
||||
}
|
||||
|
||||
.textinput {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.textinput input {
|
||||
padding: 25px 15px 10px;
|
||||
}
|
||||
|
||||
.textinput span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transform: translateZ(0);
|
||||
transition: all 0.2s, color 0s;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.textinput-1 {
|
||||
font: 12px 'Montserrat', sans-serif;
|
||||
margin: 15px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.textinput input:focus + .textinput-1,
|
||||
.textinput-1.value {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.textinput-2 {
|
||||
margin: 22.5px 15px;
|
||||
}
|
||||
|
||||
.textinput input:focus + .textinput-1 + .textinput-2,
|
||||
.textinput-2.value {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background: #f6546a;
|
||||
color: #fff;
|
||||
padding: 6px 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@ -52,11 +186,14 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
.app-info {
|
||||
width: 100%;
|
||||
font-family: Montserrat, sans-serif;
|
||||
background: #F6546A;
|
||||
color: #FFF;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background: #6bb758;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.app-info-error {
|
||||
background: #f6546a;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
@ -71,14 +208,15 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
bottom: 0;
|
||||
width: 200px;
|
||||
background: #222;
|
||||
color: #FFF;
|
||||
color: #fff;
|
||||
font-family: Montserrat, sans-serif;
|
||||
transition: transform .2s;
|
||||
transition: transform 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
top: 0;
|
||||
bottom: 50px;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
@ -101,7 +239,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
|
||||
.tablist p.selected {
|
||||
padding-left: 10px;
|
||||
border-left: 5px solid #6BB758;
|
||||
border-left: 5px solid #6bb758;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
@ -130,46 +268,32 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.button-connect {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: #6BB758;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.button-connect:hover {
|
||||
background: #7BBF6A;
|
||||
}
|
||||
|
||||
.button-connect:active {
|
||||
background: #6BB758;
|
||||
}
|
||||
|
||||
.side-buttons {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 50px;
|
||||
width: 200px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #1d1d1d;
|
||||
}
|
||||
|
||||
.side-buttons i {
|
||||
display: inline-block;
|
||||
flex: 100%;
|
||||
color: #999;
|
||||
width: 50%;
|
||||
line-height: 50px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
border-top: 1px solid #1D1D1D;
|
||||
font-size: 18px;
|
||||
border-left: 1px solid #1d1d1d;
|
||||
}
|
||||
|
||||
.side-buttons i:not(:first-child) {
|
||||
border-left: 1px solid #1D1D1D;
|
||||
.side-buttons button {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.side-buttons i:hover {
|
||||
color: #CCC;
|
||||
background: #1D1D1D;
|
||||
color: #ccc;
|
||||
background: #1d1d1d;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
@ -178,12 +302,13 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
transition: left .2s, transform .2s;
|
||||
transition: left 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.connect {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
@ -191,75 +316,96 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.connect .navicon, .settings .navicon {
|
||||
.connect .navicon,
|
||||
.settings .navicon {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.connect-form {
|
||||
margin: auto 0;
|
||||
margin: auto 20px;
|
||||
padding-top: 20px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.connect-form h1 {
|
||||
margin-bottom: 15px;
|
||||
width: 350px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connect-form input {
|
||||
display: block;
|
||||
margin: 5px 0px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
.connect-form h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.connect-form input[type="submit"],
|
||||
.connect-form input[type="text"],
|
||||
.connect-form input[type="password"] {
|
||||
.connect-details {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.connect-details h2 {
|
||||
color: #6bb758;
|
||||
}
|
||||
|
||||
.connect-form input {
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connect-form input[type="submit"] {
|
||||
height: 50px;
|
||||
margin-bottom: 20px;
|
||||
font-family: Montserrat, sans-serif;
|
||||
background: #6BB758;
|
||||
color: #FFF;
|
||||
cursor: pointer;
|
||||
input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.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;
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connect-form label {
|
||||
display: inline-block;
|
||||
padding: 10px 0;
|
||||
color: #333;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.connect-form button {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.connect-form-address {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.connect-form-address .textinput:nth-child(1) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.connect-form-address .textinput:nth-child(2) {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.connect-form-address input {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.connect-form-address label {
|
||||
margin-top: 5px;
|
||||
font: 12px 'Montserrat', sans-serif;
|
||||
padding: 10px;
|
||||
padding-bottom: 0;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.connect-form i {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.connect-form i:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chat-title-bar {
|
||||
@ -270,7 +416,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
right: 0;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
border-bottom: 1px solid #DDD;
|
||||
border-bottom: 1px solid #ddd;
|
||||
display: flex;
|
||||
font-size: 20px;
|
||||
}
|
||||
@ -296,39 +442,53 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-server .userlist, .chat-private .userlist {
|
||||
.chat-server .userlist,
|
||||
.chat-private .userlist {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-server .userlist-bar, .chat-private .userlist-bar {
|
||||
.chat-server .userlist-bar,
|
||||
.chat-private .userlist-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button-leave {
|
||||
border-left: 1px solid #DDD;
|
||||
border-left: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.button-leave:hover {
|
||||
background: #DDD;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.button-userlist {
|
||||
display: none;
|
||||
border-left: 1px solid #DDD;
|
||||
border-left: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.chat-server .button-userlist, .chat-private .button-userlist {
|
||||
.chat-server .button-userlist,
|
||||
.chat-private .button-userlist {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
margin-left: 15px;
|
||||
margin-left: 10px;
|
||||
padding: 0 5px;
|
||||
font: 24px Montserrat, sans-serif;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
background: none;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.chat-server .chat-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input.chat-title {
|
||||
background: none;
|
||||
cursor: text !important;
|
||||
}
|
||||
|
||||
.chat-topic-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@ -338,7 +498,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
.chat-topic {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
bottom: -4px;
|
||||
top: 3px;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
@ -361,8 +521,8 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
right: 0;
|
||||
width: 200px;
|
||||
height: 50px;
|
||||
border-left: 1px solid #DDD;
|
||||
border-bottom: 1px solid #DDD;
|
||||
border-left: 1px solid #ddd;
|
||||
border-bottom: 1px solid #ddd;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
padding: 0 15px;
|
||||
@ -395,13 +555,13 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
.search-input-wrap {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: #FFF;
|
||||
border-bottom: 1px solid #DDD;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.search i {
|
||||
padding: 15px;
|
||||
color: #DDD;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@ -444,8 +604,9 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.VirtualScroll {
|
||||
.messagebox-window {
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
.message {
|
||||
@ -457,17 +618,17 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
}
|
||||
|
||||
.message-error {
|
||||
color: #F6546A;
|
||||
color: #f6546a;
|
||||
}
|
||||
|
||||
.message-prompt {
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
color: #6BB758;
|
||||
color: #6bb758;
|
||||
}
|
||||
|
||||
.message-action {
|
||||
color: #FF6698;
|
||||
color: #ff6698;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
@ -478,13 +639,13 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
|
||||
.message-sender {
|
||||
font-weight: 700;
|
||||
color: #6BB758;
|
||||
color: #6bb758;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message a {
|
||||
text-decoration: none;
|
||||
color: #0066FF;
|
||||
color: #0066ff;
|
||||
}
|
||||
|
||||
.message a:hover {
|
||||
@ -499,8 +660,8 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
height: 50px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
border-top: 1px solid #DDD;
|
||||
background: #FFF;
|
||||
border-top: 1px solid #ddd;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.message-input-nick {
|
||||
@ -509,10 +670,19 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
line-height: 30px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
background: #6BB758 !important;
|
||||
color: #FFF;
|
||||
background: #6bb758;
|
||||
color: #fff;
|
||||
font-family: Montserrat, sans-serif !important;
|
||||
margin-right: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input.message-input-nick {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
input.message-input-nick.invalid {
|
||||
background: #f6546a;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
@ -528,10 +698,10 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
bottom: 50px;
|
||||
right: 0;
|
||||
width: 200px;
|
||||
border-left: 1px solid #DDD;
|
||||
border-left: 1px solid #ddd;
|
||||
background: #f0f0f0;
|
||||
z-index: 2;
|
||||
transition: transform .2s;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.userlist p {
|
||||
@ -540,80 +710,112 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
}
|
||||
|
||||
.userlist p:hover {
|
||||
background: #DDD;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings {
|
||||
flex: 1;
|
||||
max-width: 692px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings p {
|
||||
color: #999;
|
||||
.settings-section {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
margin: 0 20px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings .checkbox {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.settings h1 {
|
||||
text-align: center;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.settings h2 {
|
||||
margin: 15px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-top: 15px;
|
||||
color: #f6546a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input-file {
|
||||
color: #FFF;
|
||||
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;
|
||||
.settings-file {
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
margin-top: 15px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.rvlist-messages {
|
||||
padding: 7px 0;
|
||||
overflow-y: scroll !important;
|
||||
.button-install {
|
||||
padding: 0 15px;
|
||||
width: auto !important;
|
||||
margin: 20px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rvlist-users {
|
||||
padding: 10px 0;
|
||||
.button-install h2 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 906px) {
|
||||
.settings-file {
|
||||
display: block;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-file p {
|
||||
margin-bottom: 5px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.settings-cert {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.suspense-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 700 64px 'Montserrat', sans-serif;
|
||||
height: 100%;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tablist {
|
||||
width: 200px;
|
||||
transform: translateX(-200px);
|
||||
@ -640,6 +842,10 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.chat-topic {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.userlist-bar {
|
||||
display: none;
|
||||
}
|
||||
@ -652,7 +858,8 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.chat-channel .chat-title-bar, .chat-channel .messagebox {
|
||||
.chat-channel .chat-title-bar,
|
||||
.chat-channel .messagebox {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@ -660,10 +867,6 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chat-topic {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
right: 0;
|
||||
}
|
||||
@ -673,4 +876,12 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
|
||||
margin: auto 50px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-left: 50px;
|
||||
}
|
||||
|
||||
.button-install {
|
||||
margin-left: 50px;
|
||||
}
|
||||
}
|
@ -4,15 +4,12 @@ var url = require('url');
|
||||
|
||||
var gulp = require('gulp');
|
||||
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 proxy = require('express-http-proxy');
|
||||
var webpack = require('webpack');
|
||||
var through = require('through2');
|
||||
var br = require('brotli');
|
||||
var del = require('del');
|
||||
|
||||
function brotli(opts) {
|
||||
return through.obj(function(file, enc, callback) {
|
||||
@ -21,24 +18,23 @@ function brotli(opts) {
|
||||
}
|
||||
|
||||
if (file.isStream()) {
|
||||
this.emit('error', new gutil.PluginError('brotli', 'Streams not supported'));
|
||||
this.emit(
|
||||
'error',
|
||||
new gutil.PluginError('brotli', 'Streams not supported')
|
||||
);
|
||||
} else if (file.isBuffer()) {
|
||||
file.path += '.br';
|
||||
file.contents = new Buffer(br.compress(file.contents, opts).buffer);
|
||||
file.contents = Buffer.from(br.compress(file.contents, opts).buffer);
|
||||
return callback(null, file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
gulp.task('css', function() {
|
||||
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 clean() {
|
||||
return del(['dist']);
|
||||
}
|
||||
|
||||
gulp.task('js', function(cb) {
|
||||
function js(cb) {
|
||||
var config = require('./webpack.config.prod.js');
|
||||
var compiler = webpack(config);
|
||||
|
||||
@ -47,74 +43,74 @@ gulp.task('js', function(cb) {
|
||||
compiler.run(function(err, stats) {
|
||||
if (err) throw new gutil.PluginError('webpack', err);
|
||||
|
||||
gutil.log('[webpack]', stats.toString({
|
||||
colors: true
|
||||
}));
|
||||
gutil.log(
|
||||
'[webpack]',
|
||||
stats.toString({
|
||||
colors: true
|
||||
})
|
||||
);
|
||||
|
||||
if (stats.hasErrors()) process.exit(1);
|
||||
|
||||
cb();
|
||||
});
|
||||
});
|
||||
|
||||
gulp.task('fonts', function() {
|
||||
return gulp.src('src/font/*')
|
||||
.pipe(gulp.dest('dist/font'));
|
||||
});
|
||||
|
||||
gulp.task('fonts:woff', function() {
|
||||
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() {
|
||||
return gulp.src(['dist/**/!(*.br|*.woff|*.woff2)', '!dist/{br,br/**}'])
|
||||
.pipe(brotli({ quality: 11 }))
|
||||
.pipe(gulp.dest('dist/br'));
|
||||
}
|
||||
|
||||
gulp.task('compress', ['css', 'js', 'fonts'], compress);
|
||||
gulp.task('compress:dev', ['css', 'fonts'], compress);
|
||||
function config() {
|
||||
return gulp.src('../config.default.toml').pipe(gulp.dest('dist'));
|
||||
}
|
||||
|
||||
gulp.task('bindata', ['compress', 'config'], function(cb) {
|
||||
exec('go-bindata -nomemcopy -nocompress -pkg assets -o ../assets/bindata.go -prefix "dist/br" dist/br/...', cb);
|
||||
});
|
||||
function public() {
|
||||
return gulp.src('public/**/*').pipe(gulp.dest('dist'));
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
function compress() {
|
||||
return gulp
|
||||
.src(['dist/**/*(*.js|*.css|*.json)', '!dist/**/*(*.dev.js)'])
|
||||
.pipe(brotli({ quality: 11 }))
|
||||
.pipe(gulp.dest('dist'));
|
||||
}
|
||||
|
||||
gulp.task('dev', ['css', 'fonts', 'fonts:woff', 'config', 'compress:dev', 'bindata:dev'], function() {
|
||||
gulp.watch('src/css/*.css', ['css']);
|
||||
function cleanup() {
|
||||
return del(['dist/**/*(*.js|*.css|*.json|*.map)']);
|
||||
}
|
||||
|
||||
function bindata(cb) {
|
||||
exec(
|
||||
'go-bindata -nomemcopy -nocompress -pkg assets -o ../assets/bindata.go -prefix "dist" dist/...',
|
||||
cb
|
||||
);
|
||||
}
|
||||
|
||||
function serve() {
|
||||
var config = require('./webpack.config.dev.js');
|
||||
var compiler = webpack(config);
|
||||
var app = express();
|
||||
|
||||
app.use(require('webpack-dev-middleware')(compiler, {
|
||||
noInfo: true,
|
||||
publicPath: config.output.publicPath,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
}));
|
||||
app.use(
|
||||
require('webpack-dev-middleware')(compiler, {
|
||||
noInfo: true,
|
||||
publicPath: config.output.publicPath,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
app.use(require('webpack-hot-middleware')(compiler));
|
||||
|
||||
app.use('/', express.static('dist'));
|
||||
|
||||
app.use('*', proxy('localhost:1337', {
|
||||
proxyReqPathResolver: function(req) {
|
||||
return req.originalUrl;
|
||||
}
|
||||
}));
|
||||
app.use(
|
||||
'*',
|
||||
proxy('localhost:1337', {
|
||||
proxyReqPathResolver: function(req) {
|
||||
return req.originalUrl;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
app.listen(3000, function (err) {
|
||||
app.listen(3000, function(err) {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
return;
|
||||
@ -122,8 +118,16 @@ gulp.task('dev', ['css', 'fonts', 'fonts:woff', 'config', 'compress:dev', 'binda
|
||||
|
||||
console.log('Listening at http://localhost:3000');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
gulp.task('build', ['css', 'js', 'fonts', 'fonts:woff', 'config', 'compress', 'bindata']);
|
||||
const assets = gulp.parallel(js, config, public);
|
||||
|
||||
gulp.task('default', ['dev']);
|
||||
const build = gulp.series(clean, assets, compress, cleanup, bindata);
|
||||
|
||||
const dev = gulp.series(
|
||||
clean,
|
||||
gulp.parallel(serve, public, gulp.series(config, bindata))
|
||||
);
|
||||
|
||||
gulp.task('build', build);
|
||||
gulp.task('default', dev);
|
||||
|
14
client/js/boot.js
Normal file
14
client/js/boot.js
Normal file
@ -0,0 +1,14 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
// This entrypoint gets inlined in the index page cached by service workers
|
||||
// and is responsible for fetching the data we would otherwise embed
|
||||
|
||||
window.__env__ = fetch('/data', {
|
||||
credentials: 'same-origin'
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
throw new Error(res.statusText);
|
||||
});
|
@ -3,8 +3,11 @@ import { join, part, invite, kick, setTopic } from 'state/channels';
|
||||
import { sendMessage, raw } from 'state/messages';
|
||||
import { setNick, disconnect, whois, away } from 'state/servers';
|
||||
import { select } from 'state/tab';
|
||||
import { find } from 'util';
|
||||
import createCommandMiddleware, { beforeHandler, notFoundHandler } from './middleware/command';
|
||||
import { find } from 'utils';
|
||||
import createCommandMiddleware, {
|
||||
beforeHandler,
|
||||
notFoundHandler
|
||||
} from './middleware/command';
|
||||
|
||||
const help = [
|
||||
'/join <channel> - Join a channel',
|
||||
@ -26,7 +29,8 @@ const help = [
|
||||
const text = content => ({ content });
|
||||
const error = content => ({ content, type: 'error' });
|
||||
const prompt = content => ({ content, type: 'prompt' });
|
||||
const findHelp = cmd => find(help, line => line.slice(1, line.indexOf(' ')) === cmd);
|
||||
const findHelp = cmd =>
|
||||
find(help, line => line.slice(1, line.indexOf(' ')) === cmd);
|
||||
|
||||
export default createCommandMiddleware(COMMAND, {
|
||||
join({ dispatch, server }, channel) {
|
||||
@ -75,13 +79,13 @@ export default createCommandMiddleware(COMMAND, {
|
||||
topic({ dispatch, getState, server, channel }, ...newTopic) {
|
||||
if (newTopic.length > 0) {
|
||||
dispatch(setTopic(newTopic.join(' '), channel, server));
|
||||
} else {
|
||||
const topic = getState().channels.getIn([server, channel, 'topic']);
|
||||
} else if (channel) {
|
||||
const { topic } = getState().channels[server][channel];
|
||||
if (topic) {
|
||||
return text(topic);
|
||||
}
|
||||
return 'No topic set';
|
||||
}
|
||||
return 'No topic set';
|
||||
},
|
||||
|
||||
msg({ dispatch, server }, target, ...message) {
|
||||
@ -160,10 +164,7 @@ export default createCommandMiddleware(COMMAND, {
|
||||
dispatch(raw(cmd, server));
|
||||
return prompt(`=> ${cmd}`);
|
||||
}
|
||||
return [
|
||||
prompt('=> /raw'),
|
||||
error('Missing message')
|
||||
];
|
||||
return [prompt('=> /raw'), error('Missing message')];
|
||||
},
|
||||
|
||||
help(_, ...commands) {
|
77
client/js/components/App.js
Normal file
77
client/js/components/App.js
Normal file
@ -0,0 +1,77 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import Route from 'containers/Route';
|
||||
import AppInfo from 'components/AppInfo';
|
||||
import TabList from 'components/TabList';
|
||||
import cn from 'classnames';
|
||||
|
||||
const Chat = lazy(() => import('containers/Chat'));
|
||||
const Connect = lazy(() => import('containers/Connect'));
|
||||
const Settings = lazy(() => import('containers/Settings'));
|
||||
|
||||
const App = ({
|
||||
connected,
|
||||
tab,
|
||||
channels,
|
||||
servers,
|
||||
privateChats,
|
||||
showTabList,
|
||||
select,
|
||||
push,
|
||||
hideMenu,
|
||||
newVersionAvailable
|
||||
}) => {
|
||||
const mainClass = cn('main-container', {
|
||||
'off-canvas': showTabList
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (showTabList) {
|
||||
hideMenu();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wrap" onClick={handleClick}>
|
||||
{!connected && (
|
||||
<AppInfo type="error">
|
||||
Connection lost, attempting to reconnect...
|
||||
</AppInfo>
|
||||
)}
|
||||
{newVersionAvailable && (
|
||||
<AppInfo dismissible>
|
||||
A new version of dispatch just got installed, reload to start using
|
||||
it!
|
||||
</AppInfo>
|
||||
)}
|
||||
<div className="app-container">
|
||||
<TabList
|
||||
tab={tab}
|
||||
channels={channels}
|
||||
servers={servers}
|
||||
privateChats={privateChats}
|
||||
showTabList={showTabList}
|
||||
select={select}
|
||||
push={push}
|
||||
/>
|
||||
<div className={mainClass}>
|
||||
<Suspense
|
||||
maxDuration={1000}
|
||||
fallback={<div className="suspense-fallback">...</div>}
|
||||
>
|
||||
<Route name="chat">
|
||||
<Chat />
|
||||
</Route>
|
||||
<Route name="connect">
|
||||
<Connect />
|
||||
</Route>
|
||||
<Route name="settings">
|
||||
<Settings />
|
||||
</Route>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
28
client/js/components/AppInfo.js
Normal file
28
client/js/components/AppInfo.js
Normal file
@ -0,0 +1,28 @@
|
||||
import React, { useState } from 'react';
|
||||
import cn from 'classnames';
|
||||
|
||||
const AppInfo = ({ type, children, dismissible }) => {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (!dismissed) {
|
||||
const handleDismiss = () => {
|
||||
if (dismissible) {
|
||||
setDismissed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const className = cn('app-info', {
|
||||
[`app-info-${type}`]: type
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className} onClick={handleDismiss}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AppInfo;
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import App from 'containers/App';
|
||||
|
||||
const Root = ({ store }) => (
|
||||
@ -8,4 +9,4 @@ const Root = ({ store }) => (
|
||||
</Provider>
|
||||
);
|
||||
|
||||
export default Root;
|
||||
export default hot(module)(Root);
|
81
client/js/components/TabList.js
Normal file
81
client/js/components/TabList.js
Normal file
@ -0,0 +1,81 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Button from 'components/ui/Button';
|
||||
import TabListItem from './TabListItem';
|
||||
|
||||
export default class TabList extends PureComponent {
|
||||
handleTabClick = (server, target) => this.props.select(server, target);
|
||||
|
||||
handleConnectClick = () => this.props.push('/connect');
|
||||
|
||||
handleSettingsClick = () => this.props.push('/settings');
|
||||
|
||||
render() {
|
||||
const { tab, channels, servers, privateChats, showTabList } = this.props;
|
||||
const tabs = [];
|
||||
|
||||
const className = classnames('tablist', {
|
||||
'off-canvas': showTabList
|
||||
});
|
||||
|
||||
channels.forEach(server => {
|
||||
const { address } = server;
|
||||
const srv = servers[address];
|
||||
tabs.push(
|
||||
<TabListItem
|
||||
key={address}
|
||||
server={address}
|
||||
content={srv.name}
|
||||
selected={tab.server === address && !tab.name}
|
||||
connected={srv.status.connected}
|
||||
onClick={this.handleTabClick}
|
||||
/>
|
||||
);
|
||||
|
||||
server.channels.forEach(name =>
|
||||
tabs.push(
|
||||
<TabListItem
|
||||
key={address + name}
|
||||
server={address}
|
||||
target={name}
|
||||
content={name}
|
||||
selected={tab.server === address && tab.name === name}
|
||||
onClick={this.handleTabClick}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (privateChats[address] && privateChats[address].length > 0) {
|
||||
tabs.push(
|
||||
<div key={`${address}-pm}`} className="tab-label">
|
||||
Private messages
|
||||
</div>
|
||||
);
|
||||
|
||||
privateChats[address].forEach(nick =>
|
||||
tabs.push(
|
||||
<TabListItem
|
||||
key={address + nick}
|
||||
server={address}
|
||||
target={nick}
|
||||
content={nick}
|
||||
selected={tab.server === address && tab.name === nick}
|
||||
onClick={this.handleTabClick}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="tab-container">{tabs}</div>
|
||||
<div className="side-buttons">
|
||||
<Button onClick={this.handleConnectClick}>+</Button>
|
||||
<i className="icon-user" />
|
||||
<i className="icon-cog" onClick={this.handleSettingsClick} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
26
client/js/components/TabListItem.js
Normal file
26
client/js/components/TabListItem.js
Normal file
@ -0,0 +1,26 @@
|
||||
import React, { memo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const TabListItem = ({
|
||||
target,
|
||||
content,
|
||||
server,
|
||||
selected,
|
||||
connected,
|
||||
onClick
|
||||
}) => {
|
||||
const className = classnames({
|
||||
'tab-server': !target,
|
||||
success: !target && connected,
|
||||
error: !target && !connected,
|
||||
selected
|
||||
});
|
||||
|
||||
return (
|
||||
<p className={className} onClick={() => onClick(server, target)}>
|
||||
<span className="tab-content">{content}</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(TabListItem);
|
@ -1,4 +1,5 @@
|
||||
import React, { Component } from 'react';
|
||||
import { isChannel } from 'utils';
|
||||
import ChatTitle from './ChatTitle';
|
||||
import Search from './Search';
|
||||
import MessageBox from './MessageBox';
|
||||
@ -9,7 +10,7 @@ export default class Chat extends Component {
|
||||
handleCloseClick = () => {
|
||||
const { tab, part, closePrivateChat, disconnect } = this.props;
|
||||
|
||||
if (tab.isChannel()) {
|
||||
if (isChannel(tab)) {
|
||||
part([tab.name], tab.server);
|
||||
} else if (tab.name) {
|
||||
closePrivateChat(tab.server, tab.name);
|
||||
@ -20,7 +21,7 @@ export default class Chat extends Component {
|
||||
|
||||
handleSearch = phrase => {
|
||||
const { tab, searchMessages } = this.props;
|
||||
if (tab.isChannel()) {
|
||||
if (isChannel(tab)) {
|
||||
searchMessages(tab.server, tab.name, phrase);
|
||||
}
|
||||
};
|
||||
@ -49,12 +50,14 @@ export default class Chat extends Component {
|
||||
render() {
|
||||
const {
|
||||
channel,
|
||||
coloredNicks,
|
||||
currentInputHistoryEntry,
|
||||
hasMoreMessages,
|
||||
messages,
|
||||
nick,
|
||||
search,
|
||||
showUserList,
|
||||
status,
|
||||
tab,
|
||||
title,
|
||||
users,
|
||||
@ -67,9 +70,8 @@ export default class Chat extends Component {
|
||||
toggleSearch,
|
||||
toggleUserList
|
||||
} = this.props;
|
||||
|
||||
let chatClass;
|
||||
if (tab.isChannel()) {
|
||||
if (isChannel(tab)) {
|
||||
chatClass = 'chat-channel';
|
||||
} else if (tab.name) {
|
||||
chatClass = 'chat-private';
|
||||
@ -81,6 +83,7 @@ export default class Chat extends Component {
|
||||
<div className={chatClass}>
|
||||
<ChatTitle
|
||||
channel={channel}
|
||||
status={status}
|
||||
tab={tab}
|
||||
title={title}
|
||||
onCloseClick={this.handleCloseClick}
|
||||
@ -88,11 +91,9 @@ export default class Chat extends Component {
|
||||
onToggleSearch={toggleSearch}
|
||||
onToggleUserList={toggleUserList}
|
||||
/>
|
||||
<Search
|
||||
search={search}
|
||||
onSearch={this.handleSearch}
|
||||
/>
|
||||
<Search search={search} onSearch={this.handleSearch} />
|
||||
<MessageBox
|
||||
coloredNicks={coloredNicks}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
messages={messages}
|
||||
tab={tab}
|
||||
@ -111,6 +112,7 @@ export default class Chat extends Component {
|
||||
{...inputActions}
|
||||
/>
|
||||
<UserList
|
||||
coloredNicks={coloredNicks}
|
||||
showUserList={showUserList}
|
||||
users={users}
|
||||
onNickClick={this.handleNickClick}
|
73
client/js/components/pages/Chat/ChatTitle.js
Normal file
73
client/js/components/pages/Chat/ChatTitle.js
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { memo } from 'react';
|
||||
import Navicon from 'containers/Navicon';
|
||||
import Editable from 'components/ui/Editable';
|
||||
import { isValidServerName } from 'state/servers';
|
||||
import { isChannel, linkify } from 'utils';
|
||||
|
||||
const ChatTitle = ({
|
||||
status,
|
||||
title,
|
||||
tab,
|
||||
channel,
|
||||
onTitleChange,
|
||||
onToggleSearch,
|
||||
onToggleUserList,
|
||||
onCloseClick
|
||||
}) => {
|
||||
let closeTitle;
|
||||
if (isChannel(tab)) {
|
||||
closeTitle = 'Leave';
|
||||
} else if (tab.name) {
|
||||
closeTitle = 'Close';
|
||||
} else {
|
||||
closeTitle = 'Disconnect';
|
||||
}
|
||||
|
||||
let serverError = null;
|
||||
if (!tab.name && status.error) {
|
||||
serverError = (
|
||||
<span className="chat-topic error">
|
||||
Error:
|
||||
{status.error}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="chat-title-bar">
|
||||
<Navicon />
|
||||
<Editable
|
||||
className="chat-title"
|
||||
editable={!tab.name}
|
||||
value={title}
|
||||
validate={isValidServerName}
|
||||
onChange={onTitleChange}
|
||||
>
|
||||
<span className="chat-title">{title}</span>
|
||||
</Editable>
|
||||
<div className="chat-topic-wrap">
|
||||
<span className="chat-topic">
|
||||
{channel && linkify(channel.topic)}
|
||||
</span>
|
||||
{serverError}
|
||||
</div>
|
||||
<i className="icon-search" title="Search" onClick={onToggleSearch} />
|
||||
<i
|
||||
className="icon-cancel button-leave"
|
||||
title={closeTitle}
|
||||
onClick={onCloseClick}
|
||||
/>
|
||||
<i className="icon-user button-userlist" onClick={onToggleUserList} />
|
||||
</div>
|
||||
<div className="userlist-bar">
|
||||
<i className="icon-user" />
|
||||
<span className="chat-usercount">
|
||||
{channel && channel.users.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ChatTitle);
|
38
client/js/components/pages/Chat/Message.js
Normal file
38
client/js/components/pages/Chat/Message.js
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { memo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import stringToRGB from 'utils/color';
|
||||
|
||||
const Message = ({ message, coloredNick, style, onNickClick }) => {
|
||||
const className = classnames('message', {
|
||||
[`message-${message.type}`]: message.type
|
||||
});
|
||||
|
||||
style = {
|
||||
...style,
|
||||
paddingLeft: `${window.messageIndent + 15}px`,
|
||||
textIndent: `-${window.messageIndent}px`
|
||||
};
|
||||
|
||||
const senderStyle = {};
|
||||
if (message.from && coloredNick) {
|
||||
senderStyle.color = stringToRGB(message.from);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={className} style={style}>
|
||||
<span className="message-time">{message.time} </span>
|
||||
{message.from && (
|
||||
<span
|
||||
className="message-sender"
|
||||
style={senderStyle}
|
||||
onClick={() => onNickClick(message.from)}
|
||||
>
|
||||
{message.from}
|
||||
</span>
|
||||
)}
|
||||
<span> {message.content}</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Message);
|
250
client/js/components/pages/Chat/MessageBox.js
Normal file
250
client/js/components/pages/Chat/MessageBox.js
Normal file
@ -0,0 +1,250 @@
|
||||
import React, { PureComponent, createRef } from 'react';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { getScrollPos, saveScrollPos } from 'utils/scrollPosition';
|
||||
import Message from './Message';
|
||||
|
||||
const fetchThreshold = 600;
|
||||
// The amount of time in ms that needs to pass without any
|
||||
// scroll events happening before adding messages to the top,
|
||||
// this is done to prevent the scroll from jumping all over the place
|
||||
const scrollbackDebounce = 100;
|
||||
|
||||
export default class MessageBox extends PureComponent {
|
||||
list = createRef();
|
||||
outer = createRef();
|
||||
|
||||
addMore = debounce(() => {
|
||||
const { tab, onAddMore } = this.props;
|
||||
this.ready = true;
|
||||
onAddMore(tab.server, tab.name);
|
||||
}, scrollbackDebounce);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.loadScrollPos();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const scrollToBottom = this.bottom;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
const { messages } = this.props;
|
||||
|
||||
if (scrollToBottom && messages.length > 0) {
|
||||
this.list.current.scrollToItem(messages.length + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.tab !== this.props.tab) {
|
||||
this.loadScrollPos(true);
|
||||
}
|
||||
|
||||
if (this.nextScrollTop > 0) {
|
||||
this.list.current.scrollTo(this.nextScrollTop);
|
||||
this.nextScrollTop = 0;
|
||||
} else if (this.bottom) {
|
||||
this.list.current.scrollToItem(this.props.messages.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.saveScrollPos();
|
||||
}
|
||||
|
||||
getSnapshotBeforeUpdate(prevProps) {
|
||||
if (prevProps.messages !== this.props.messages) {
|
||||
this.list.current.resetAfterIndex(0);
|
||||
}
|
||||
|
||||
if (prevProps.tab !== this.props.tab) {
|
||||
this.saveScrollPos();
|
||||
this.bottom = false;
|
||||
}
|
||||
|
||||
if (prevProps.messages[0] !== this.props.messages[0]) {
|
||||
const { messages, hasMoreMessages } = this.props;
|
||||
|
||||
if (prevProps.tab === this.props.tab) {
|
||||
const addedMessages = messages.length - prevProps.messages.length;
|
||||
let addedHeight = 0;
|
||||
for (let i = 0; i < addedMessages; i++) {
|
||||
addedHeight += messages[i].height;
|
||||
}
|
||||
|
||||
this.nextScrollTop = addedHeight + this.outer.current.scrollTop;
|
||||
|
||||
if (!hasMoreMessages) {
|
||||
this.nextScrollTop -= 93;
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getRowHeight = index => {
|
||||
const { messages, hasMoreMessages } = this.props;
|
||||
|
||||
if (index === 0) {
|
||||
if (hasMoreMessages) {
|
||||
return 100;
|
||||
}
|
||||
return 7;
|
||||
} else if (index === messages.length + 1) {
|
||||
return 7;
|
||||
}
|
||||
return messages[index - 1].height;
|
||||
};
|
||||
|
||||
getItemKey = index => {
|
||||
const { messages } = this.props;
|
||||
|
||||
if (index === 0) {
|
||||
return 'top';
|
||||
} else if (index === messages.length + 1) {
|
||||
return 'bottom';
|
||||
}
|
||||
return messages[index - 1].id;
|
||||
};
|
||||
|
||||
updateScrollKey = () => {
|
||||
const { tab } = this.props;
|
||||
this.scrollKey = `msg:${tab.server}:${tab.name}`;
|
||||
return this.scrollKey;
|
||||
};
|
||||
|
||||
loadScrollPos = scroll => {
|
||||
const pos = getScrollPos(this.updateScrollKey());
|
||||
if (pos >= 0) {
|
||||
this.bottom = false;
|
||||
if (scroll) {
|
||||
this.list.current.scrollTo(pos);
|
||||
} else {
|
||||
this.initialScrollTop = pos;
|
||||
}
|
||||
} else {
|
||||
this.bottom = true;
|
||||
if (scroll) {
|
||||
this.list.current.scrollToItem(this.props.messages.length + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
saveScrollPos = () => {
|
||||
if (this.bottom) {
|
||||
saveScrollPos(this.scrollKey, -1);
|
||||
} else {
|
||||
saveScrollPos(this.scrollKey, this.outer.current.scrollTop);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMore = () => {
|
||||
this.loading = true;
|
||||
this.props.onFetchMore();
|
||||
};
|
||||
|
||||
handleScroll = ({ scrollOffset, scrollDirection }) => {
|
||||
if (
|
||||
!this.loading &&
|
||||
this.props.hasMoreMessages &&
|
||||
scrollOffset <= fetchThreshold &&
|
||||
scrollDirection === 'backward'
|
||||
) {
|
||||
this.fetchMore();
|
||||
}
|
||||
|
||||
if (this.loading && !this.ready) {
|
||||
if (this.mouseDown) {
|
||||
this.ready = true;
|
||||
this.shouldAdd = true;
|
||||
} else {
|
||||
this.addMore();
|
||||
}
|
||||
}
|
||||
|
||||
const { clientHeight, scrollHeight } = this.outer.current;
|
||||
|
||||
this.bottom = scrollOffset + clientHeight >= scrollHeight - 20;
|
||||
};
|
||||
|
||||
handleMouseDown = () => {
|
||||
this.mouseDown = true;
|
||||
};
|
||||
|
||||
handleMouseUp = () => {
|
||||
this.mouseDown = false;
|
||||
|
||||
if (this.shouldAdd) {
|
||||
const { tab, onAddMore } = this.props;
|
||||
this.shouldAdd = false;
|
||||
onAddMore(tab.server, tab.name);
|
||||
}
|
||||
};
|
||||
|
||||
renderMessage = ({ index, style }) => {
|
||||
const { messages } = this.props;
|
||||
|
||||
if (index === 0) {
|
||||
if (this.props.hasMoreMessages) {
|
||||
return (
|
||||
<div className="messagebox-top-indicator" style={style}>
|
||||
Loading messages...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} else if (index === messages.length + 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { coloredNicks, onNickClick } = this.props;
|
||||
const message = messages[index - 1];
|
||||
|
||||
return (
|
||||
<Message
|
||||
message={message}
|
||||
coloredNick={coloredNicks}
|
||||
style={style}
|
||||
onNickClick={onNickClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className="messagebox"
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
ref={this.list}
|
||||
outerRef={this.outer}
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={this.props.messages.length + 2}
|
||||
itemKey={this.getItemKey}
|
||||
itemSize={this.getRowHeight}
|
||||
estimatedItemSize={32}
|
||||
initialScrollOffset={this.initialScrollTop}
|
||||
onScroll={this.handleScroll}
|
||||
className="messagebox-window"
|
||||
>
|
||||
{this.renderMessage}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
68
client/js/components/pages/Chat/MessageInput.js
Normal file
68
client/js/components/pages/Chat/MessageInput.js
Normal file
@ -0,0 +1,68 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Editable from 'components/ui/Editable';
|
||||
import { isValidNick } from 'utils';
|
||||
|
||||
const MessageInput = ({
|
||||
nick,
|
||||
currentHistoryEntry,
|
||||
onNickChange,
|
||||
onNickEditDone,
|
||||
tab,
|
||||
onCommand,
|
||||
onMessage,
|
||||
add,
|
||||
reset,
|
||||
increment,
|
||||
decrement
|
||||
}) => {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const handleKey = e => {
|
||||
if (e.key === 'Enter' && e.target.value) {
|
||||
if (e.target.value[0] === '/') {
|
||||
onCommand(e.target.value, tab.name, tab.server);
|
||||
} else if (tab.name) {
|
||||
onMessage(e.target.value, tab.name, tab.server);
|
||||
}
|
||||
|
||||
add(e.target.value);
|
||||
reset();
|
||||
setValue('');
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
increment();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
decrement();
|
||||
} else if (currentHistoryEntry) {
|
||||
setValue(e.target.value);
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = e => setValue(e.target.value);
|
||||
|
||||
return (
|
||||
<div className="message-input-wrap">
|
||||
<Editable
|
||||
className={classnames('message-input-nick', {
|
||||
invalid: !isValidNick(nick)
|
||||
})}
|
||||
value={nick}
|
||||
onBlur={onNickEditDone}
|
||||
onChange={onNickChange}
|
||||
>
|
||||
<span className="message-input-nick">{nick}</span>
|
||||
</Editable>
|
||||
<input
|
||||
className="message-input"
|
||||
type="text"
|
||||
value={currentHistoryEntry || value}
|
||||
onKeyDown={handleKey}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MessageInput);
|
41
client/js/components/pages/Chat/Search.js
Normal file
41
client/js/components/pages/Chat/Search.js
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { memo, useRef, useEffect } from 'react';
|
||||
import SearchResult from './SearchResult';
|
||||
|
||||
const Search = ({ search, onSearch }) => {
|
||||
const inputEl = useRef();
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (search.show) {
|
||||
inputEl.current.focus();
|
||||
}
|
||||
},
|
||||
[search.show]
|
||||
);
|
||||
|
||||
const style = {
|
||||
display: search.show ? 'block' : 'none'
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
const results = search.results.map(result => (
|
||||
<SearchResult key={i++} result={result} />
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="search" style={style}>
|
||||
<div className="search-input-wrap">
|
||||
<i className="icon-search" />
|
||||
<input
|
||||
ref={inputEl}
|
||||
className="search-input"
|
||||
type="text"
|
||||
onChange={e => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="search-results">{results}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Search);
|
24
client/js/components/pages/Chat/SearchResult.js
Normal file
24
client/js/components/pages/Chat/SearchResult.js
Normal file
@ -0,0 +1,24 @@
|
||||
import React, { memo } from 'react';
|
||||
import { timestamp, linkify } from 'utils';
|
||||
|
||||
const SearchResult = ({ result }) => {
|
||||
const style = {
|
||||
paddingLeft: `${window.messageIndent}px`,
|
||||
textIndent: `-${window.messageIndent}px`
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="search-result" style={style}>
|
||||
<span className="message-time">
|
||||
{timestamp(new Date(result.time * 1000))}
|
||||
</span>
|
||||
<span>
|
||||
{' '}
|
||||
<span className="message-sender">{result.from}</span>
|
||||
</span>
|
||||
<span> {linkify(result.content)}</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SearchResult);
|
92
client/js/components/pages/Chat/UserList.js
Normal file
92
client/js/components/pages/Chat/UserList.js
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { PureComponent, createRef } from 'react';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import classnames from 'classnames';
|
||||
import UserListItem from './UserListItem';
|
||||
|
||||
export default class UserList extends PureComponent {
|
||||
list = createRef();
|
||||
|
||||
getSnapshotBeforeUpdate(prevProps) {
|
||||
if (this.list.current) {
|
||||
const { users } = this.props;
|
||||
|
||||
if (prevProps.users.length !== users.length) {
|
||||
this.list.current.resetAfterIndex(
|
||||
Math.min(prevProps.users.length, users.length) + 1
|
||||
);
|
||||
} else {
|
||||
this.list.current.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getItemHeight = index => {
|
||||
const { users } = this.props;
|
||||
|
||||
if (index === 0) {
|
||||
return 12;
|
||||
} else if (index === users.length + 1) {
|
||||
return 10;
|
||||
}
|
||||
return 24;
|
||||
};
|
||||
|
||||
getItemKey = index => {
|
||||
const { users } = this.props;
|
||||
|
||||
if (index === 0) {
|
||||
return 'top';
|
||||
} else if (index === users.length + 1) {
|
||||
return 'bottom';
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
renderUser = ({ index, style }) => {
|
||||
const { users, coloredNicks, onNickClick } = this.props;
|
||||
|
||||
if (index === 0 || index === users.length + 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserListItem
|
||||
user={users[index - 1]}
|
||||
coloredNick={coloredNicks}
|
||||
style={style}
|
||||
onClick={onNickClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { users, showUserList } = this.props;
|
||||
|
||||
const className = classnames('userlist', {
|
||||
'off-canvas': showUserList
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
<List
|
||||
ref={this.list}
|
||||
width={200}
|
||||
height={height}
|
||||
itemCount={users.length + 2}
|
||||
itemKey={this.getItemKey}
|
||||
itemSize={this.getItemHeight}
|
||||
estimatedItemSize={24}
|
||||
>
|
||||
{this.renderUser}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
19
client/js/components/pages/Chat/UserListItem.js
Normal file
19
client/js/components/pages/Chat/UserListItem.js
Normal file
@ -0,0 +1,19 @@
|
||||
import React, { memo } from 'react';
|
||||
import stringToRGB from 'utils/color';
|
||||
|
||||
const UserListItem = ({ user, coloredNick, style, onClick }) => {
|
||||
if (coloredNick) {
|
||||
style = {
|
||||
...style,
|
||||
color: stringToRGB(user.nick)
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<p style={style} onClick={() => onClick(user.nick)}>
|
||||
{user.renderName}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(UserListItem);
|
190
client/js/components/pages/Connect.js
Normal file
190
client/js/components/pages/Connect.js
Normal file
@ -0,0 +1,190 @@
|
||||
import React, { Component } from 'react';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Form, withFormik } from 'formik';
|
||||
import Navicon from 'containers/Navicon';
|
||||
import Button from 'components/ui/Button';
|
||||
import Checkbox from 'components/ui/formik/Checkbox';
|
||||
import TextInput from 'components/ui/TextInput';
|
||||
import Error from 'components/ui/formik/Error';
|
||||
import { isValidNick, isValidChannel, isValidUsername, isInt } from 'utils';
|
||||
|
||||
const getSortedDefaultChannels = createSelector(
|
||||
defaults => defaults.channels,
|
||||
channels => channels.split(',').sort()
|
||||
);
|
||||
|
||||
class Connect extends Component {
|
||||
state = {
|
||||
showOptionals: false
|
||||
};
|
||||
|
||||
handleSSLChange = e => {
|
||||
const { values, setFieldValue } = this.props;
|
||||
if (e.target.checked && values.port === 6667) {
|
||||
setFieldValue('port', 6697, false);
|
||||
} else if (!e.target.checked && values.port === 6697) {
|
||||
setFieldValue('port', 6667, false);
|
||||
}
|
||||
};
|
||||
|
||||
handleShowClick = () => {
|
||||
this.setState(prevState => ({ showOptionals: !prevState.showOptionals }));
|
||||
};
|
||||
|
||||
renderOptionals = () => {
|
||||
const { hexIP } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!hexIP && <TextInput name="username" />}
|
||||
<TextInput name="password" type="password" />
|
||||
<TextInput name="realname" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { defaults, values } = this.props;
|
||||
const { readOnly, showDetails } = defaults;
|
||||
let form;
|
||||
|
||||
if (readOnly) {
|
||||
form = (
|
||||
<Form className="connect-form">
|
||||
<h1>Connect</h1>
|
||||
{showDetails && (
|
||||
<div className="connect-details">
|
||||
<h2>
|
||||
{values.host}:{values.port}
|
||||
</h2>
|
||||
{getSortedDefaultChannels(values).map(channel => (
|
||||
<p>{channel}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TextInput name="nick" />
|
||||
<Button type="submit">Connect</Button>
|
||||
</Form>
|
||||
);
|
||||
} else {
|
||||
form = (
|
||||
<Form className="connect-form">
|
||||
<h1>Connect</h1>
|
||||
<TextInput name="name" autoCapitalize="words" />
|
||||
<div className="connect-form-address">
|
||||
<TextInput name="host" noError />
|
||||
<TextInput name="port" type="number" noError />
|
||||
<Checkbox
|
||||
name="tls"
|
||||
label="SSL"
|
||||
topLabel
|
||||
onChange={this.handleSSLChange}
|
||||
/>
|
||||
</div>
|
||||
<Error name="host" />
|
||||
<Error name="port" />
|
||||
<TextInput name="nick" />
|
||||
<TextInput name="channels" />
|
||||
{this.state.showOptionals && this.renderOptionals()}
|
||||
<i className="icon-ellipsis" onClick={this.handleShowClick} />
|
||||
<Button type="submit">Connect</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="connect">
|
||||
<Navicon />
|
||||
{form}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withFormik({
|
||||
enableReinitialize: true,
|
||||
mapPropsToValues: ({ defaults }) => {
|
||||
let port = 6667;
|
||||
if (defaults.port) {
|
||||
({ port } = defaults);
|
||||
} else if (defaults.ssl) {
|
||||
port = 6697;
|
||||
}
|
||||
|
||||
return {
|
||||
name: defaults.name,
|
||||
host: defaults.host,
|
||||
port,
|
||||
nick: '',
|
||||
channels: defaults.channels.join(','),
|
||||
username: '',
|
||||
password: defaults.password ? ' ' : '',
|
||||
realname: '',
|
||||
tls: defaults.ssl
|
||||
};
|
||||
},
|
||||
validate: values => {
|
||||
Object.keys(values).forEach(k => {
|
||||
if (typeof values[k] === 'string') {
|
||||
values[k] = values[k].trim();
|
||||
}
|
||||
});
|
||||
|
||||
const errors = {};
|
||||
|
||||
if (!values.host) {
|
||||
errors.host = 'Host is required';
|
||||
} else if (values.host.indexOf('.') < 1) {
|
||||
errors.host = 'Invalid host';
|
||||
}
|
||||
|
||||
if (!values.port) {
|
||||
values.port = values.tls ? 6697 : 6667;
|
||||
} else if (!isInt(values.port, 1, 65535)) {
|
||||
errors.port = 'Invalid port';
|
||||
}
|
||||
|
||||
if (!values.nick) {
|
||||
errors.nick = 'Nick is required';
|
||||
} else if (!isValidNick(values.nick)) {
|
||||
errors.nick = 'Invalid nick';
|
||||
}
|
||||
|
||||
if (values.username && !isValidUsername(values.username)) {
|
||||
errors.username = 'Invalid username';
|
||||
}
|
||||
|
||||
values.channels = values.channels
|
||||
.split(',')
|
||||
.map(channel => {
|
||||
channel = channel.trim();
|
||||
if (channel) {
|
||||
if (isValidChannel(channel, false)) {
|
||||
if (channel[0] !== '#') {
|
||||
channel = `#${channel}`;
|
||||
}
|
||||
} else {
|
||||
errors.channels = 'Invalid channel(s)';
|
||||
}
|
||||
}
|
||||
return channel;
|
||||
})
|
||||
.filter(s => s)
|
||||
.join(',');
|
||||
|
||||
return errors;
|
||||
},
|
||||
handleSubmit: (values, { props }) => {
|
||||
const { connect, select, join } = props;
|
||||
const channels = values.channels.split(',');
|
||||
delete values.channels;
|
||||
|
||||
values.port = `${values.port}`;
|
||||
connect(values);
|
||||
select(values.host);
|
||||
|
||||
if (channels.length > 0) {
|
||||
join(channels, values.host);
|
||||
}
|
||||
}
|
||||
})(Connect);
|
79
client/js/components/pages/Settings.js
Normal file
79
client/js/components/pages/Settings.js
Normal file
@ -0,0 +1,79 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Navicon from 'containers/Navicon';
|
||||
import Button from 'components/ui/Button';
|
||||
import Checkbox from 'components/ui/Checkbox';
|
||||
import FileInput from 'components/ui/FileInput';
|
||||
|
||||
const Settings = ({
|
||||
settings,
|
||||
installable,
|
||||
setSetting,
|
||||
onCertChange,
|
||||
onKeyChange,
|
||||
onInstall,
|
||||
uploadCert
|
||||
}) => {
|
||||
const status = settings.uploadingCert ? 'Uploading...' : 'Upload';
|
||||
const error = settings.certError;
|
||||
|
||||
const handleInstallClick = useCallback(
|
||||
async () => {
|
||||
installable.prompt();
|
||||
await installable.userChoice;
|
||||
onInstall();
|
||||
},
|
||||
[installable]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="settings-container">
|
||||
<div className="settings">
|
||||
<Navicon />
|
||||
<h1>Settings</h1>
|
||||
{installable && (
|
||||
<Button className="button-install" onClick={handleInstallClick}>
|
||||
<h2>Install</h2>
|
||||
</Button>
|
||||
)}
|
||||
<div className="settings-section">
|
||||
<h2>Visuals</h2>
|
||||
<Checkbox
|
||||
name="coloredNicks"
|
||||
label="Colored nicks"
|
||||
checked={settings.coloredNicks}
|
||||
onChange={e => setSetting('coloredNicks', e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<h2>Client Certificate</h2>
|
||||
<div className="settings-cert">
|
||||
<div className="settings-file">
|
||||
<p>Certificate</p>
|
||||
<FileInput
|
||||
name={settings.certFile || 'Select Certificate'}
|
||||
onChange={onCertChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-file">
|
||||
<p>Private Key</p>
|
||||
<FileInput
|
||||
name={settings.keyFile || 'Select Key'}
|
||||
onChange={onKeyChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="settings-button"
|
||||
onClick={uploadCert}
|
||||
>
|
||||
{status}
|
||||
</Button>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
9
client/js/components/ui/Button.js
Normal file
9
client/js/components/ui/Button.js
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const Button = ({ children, ...props }) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default Button;
|
18
client/js/components/ui/Checkbox.js
Normal file
18
client/js/components/ui/Checkbox.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const Checkbox = ({ name, label, topLabel, ...props }) => (
|
||||
<label
|
||||
className={classnames('checkbox', {
|
||||
'top-label': topLabel
|
||||
})}
|
||||
htmlFor={name}
|
||||
>
|
||||
{topLabel && label}
|
||||
<input type="checkbox" id={name} name={name} {...props} />
|
||||
<span />
|
||||
{!topLabel && label}
|
||||
</label>
|
||||
);
|
||||
|
||||
export default Checkbox;
|
107
client/js/components/ui/Editable.js
Normal file
107
client/js/components/ui/Editable.js
Normal file
@ -0,0 +1,107 @@
|
||||
import React, { PureComponent, createRef } from 'react';
|
||||
import { stringWidth } from 'utils';
|
||||
|
||||
export default class Editable extends PureComponent {
|
||||
static defaultProps = {
|
||||
editable: true
|
||||
};
|
||||
|
||||
inputEl = createRef();
|
||||
|
||||
state = {
|
||||
editing: false
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (!prevState.editing && this.state.editing) {
|
||||
// eslint-disable-next-line react/no-did-update-set-state
|
||||
this.updateInputWidth(this.props.value);
|
||||
this.inputEl.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshotBeforeUpdate(prevProps) {
|
||||
if (this.state.editing && prevProps.value !== this.props.value) {
|
||||
this.updateInputWidth(this.props.value);
|
||||
}
|
||||
}
|
||||
|
||||
updateInputWidth = value => {
|
||||
if (this.inputEl.current) {
|
||||
const style = window.getComputedStyle(this.inputEl.current);
|
||||
const padding = parseInt(style.paddingRight, 10);
|
||||
// Make sure the width is at least 1px so the caret always shows
|
||||
const width =
|
||||
stringWidth(value, `${style.fontSize} ${style.fontFamily}`) || 1;
|
||||
|
||||
this.setState({
|
||||
width: width + padding * 2,
|
||||
indent: padding
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
startEditing = () => {
|
||||
if (this.props.editable) {
|
||||
this.initialValue = this.props.value;
|
||||
this.setState({ editing: true });
|
||||
}
|
||||
};
|
||||
|
||||
stopEditing = () => {
|
||||
const { validate, value, onChange } = this.props;
|
||||
if (validate && !validate(value)) {
|
||||
onChange(this.initialValue);
|
||||
}
|
||||
this.setState({ editing: false });
|
||||
};
|
||||
|
||||
handleBlur = e => {
|
||||
const { onBlur } = this.props;
|
||||
this.stopEditing();
|
||||
if (onBlur) {
|
||||
onBlur(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
handleChange = e => this.props.onChange(e.target.value);
|
||||
|
||||
handleKey = e => {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleBlur(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = e => {
|
||||
const val = e.target.value;
|
||||
e.target.value = '';
|
||||
e.target.value = val;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, className, value } = this.props;
|
||||
|
||||
const style = {
|
||||
width: this.state.width,
|
||||
textIndent: this.state.indent,
|
||||
paddingLeft: 0
|
||||
};
|
||||
|
||||
return this.state.editing ? (
|
||||
<input
|
||||
ref={this.inputEl}
|
||||
className={className}
|
||||
type="text"
|
||||
value={value}
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKey}
|
||||
onFocus={this.handleFocus}
|
||||
style={style}
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div onClick={this.startEditing}>{children}</div>
|
||||
);
|
||||
}
|
||||
}
|
48
client/js/components/ui/FileInput.js
Normal file
48
client/js/components/ui/FileInput.js
Normal file
@ -0,0 +1,48 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Button from 'components/ui/Button';
|
||||
|
||||
export default class FileInput extends PureComponent {
|
||||
static defaultProps = {
|
||||
type: 'text'
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.input = window.document.createElement('input');
|
||||
this.input.setAttribute('type', 'file');
|
||||
|
||||
this.input.addEventListener('change', e => {
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
const { onChange, type } = this.props;
|
||||
|
||||
reader.onload = () => {
|
||||
onChange(file.name, reader.result);
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'binary':
|
||||
reader.readAsArrayBuffer(file);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
reader.readAsText(file);
|
||||
break;
|
||||
|
||||
default:
|
||||
reader.readAsText(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleClick = () => this.input.click();
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Button className="input-file" onClick={this.handleClick}>
|
||||
{this.props.name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
87
client/js/components/ui/TextInput.js
Normal file
87
client/js/components/ui/TextInput.js
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { FastField } from 'formik';
|
||||
import classnames from 'classnames';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import Error from 'components/ui/formik/Error';
|
||||
|
||||
export default class TextInput extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.input = React.createRef();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
if (this.scroll) {
|
||||
this.scroll = false;
|
||||
this.scrollIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.scroll = true;
|
||||
setTimeout(() => {
|
||||
this.scroll = false;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
scrollIntoView = () => {
|
||||
if (this.input.current.scrollIntoViewIfNeeded) {
|
||||
this.input.current.scrollIntoViewIfNeeded();
|
||||
} else {
|
||||
this.input.current.scrollIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, label = capitalize(name), noError, ...props } = this.props;
|
||||
|
||||
return (
|
||||
<FastField
|
||||
name={name}
|
||||
render={({ field, form }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="textinput">
|
||||
<input
|
||||
className={field.value && 'value'}
|
||||
type="text"
|
||||
name={name}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
ref={this.input}
|
||||
onFocus={this.handleFocus}
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
<span
|
||||
className={classnames('textinput-1', {
|
||||
value: field.value,
|
||||
error: form.touched[name] && form.errors[name]
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={classnames('textinput-2', {
|
||||
value: field.value,
|
||||
error: form.touched[name] && form.errors[name]
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{!noError && <Error name={name} />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
27
client/js/components/ui/formik/Checkbox.js
Normal file
27
client/js/components/ui/formik/Checkbox.js
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { memo } from 'react';
|
||||
import { FastField } from 'formik';
|
||||
import Checkbox from 'components/ui/Checkbox';
|
||||
|
||||
const FormikCheckbox = ({ name, onChange, ...props }) => (
|
||||
<FastField
|
||||
name={name}
|
||||
render={({ field, form }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
name={name}
|
||||
checked={field.value}
|
||||
onChange={e => {
|
||||
form.setFieldTouched(name, true);
|
||||
field.onChange(e);
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export default memo(FormikCheckbox);
|
8
client/js/components/ui/formik/Error.js
Normal file
8
client/js/components/ui/formik/Error.js
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { ErrorMessage } from 'formik';
|
||||
|
||||
const Error = props => (
|
||||
<ErrorMessage component="div" className="form-error" {...props} />
|
||||
);
|
||||
|
||||
export default Error;
|
0
client/js/components/ui/formik/TextInput.js
Normal file
0
client/js/components/ui/formik/TextInput.js
Normal file
@ -1,23 +1,27 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createStructuredSelector } from 'reselect';
|
||||
import App from 'components/App';
|
||||
import { getConnected } from 'state/app';
|
||||
import { getSortedChannels } from 'state/channels';
|
||||
import { getSortedPrivateChats } from 'state/privateChats';
|
||||
import { getPrivateChats } from 'state/privateChats';
|
||||
import { getServers } from 'state/servers';
|
||||
import { getSelectedTab, select } from 'state/tab';
|
||||
import { getShowTabList, hideMenu } from 'state/ui';
|
||||
import { push } from 'util/router';
|
||||
import connect from 'utils/connect';
|
||||
import { push } from 'utils/router';
|
||||
|
||||
const mapState = createStructuredSelector({
|
||||
channels: getSortedChannels,
|
||||
connected: getConnected,
|
||||
privateChats: getSortedPrivateChats,
|
||||
privateChats: getPrivateChats,
|
||||
servers: getServers,
|
||||
showTabList: getShowTabList,
|
||||
tab: getSelectedTab
|
||||
tab: getSelectedTab,
|
||||
newVersionAvailable: state => state.app.newVersionAvailable
|
||||
});
|
||||
|
||||
const mapDispatch = { push, select, hideMenu };
|
||||
|
||||
export default connect(mapState, mapDispatch)(App);
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(App);
|
89
client/js/containers/Chat.js
Normal file
89
client/js/containers/Chat.js
Normal file
@ -0,0 +1,89 @@
|
||||
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 { openPrivateChat, closePrivateChat } from 'state/privateChats';
|
||||
import { getSearch, searchMessages, toggleSearch } from 'state/search';
|
||||
import {
|
||||
getCurrentNick,
|
||||
getCurrentServerStatus,
|
||||
disconnect,
|
||||
setNick,
|
||||
setServerName
|
||||
} from 'state/servers';
|
||||
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,
|
||||
status: getCurrentServerStatus,
|
||||
tab: getSelectedTab,
|
||||
title: getSelectedTabTitle,
|
||||
users: getSelectedChannelUsers,
|
||||
coloredNicks: state => getSettings(state).coloredNicks
|
||||
});
|
||||
|
||||
const mapDispatch = dispatch => ({
|
||||
...bindActionCreators(
|
||||
{
|
||||
addFetchedMessages,
|
||||
closePrivateChat,
|
||||
disconnect,
|
||||
fetchMessages,
|
||||
openPrivateChat,
|
||||
part,
|
||||
runCommand,
|
||||
searchMessages,
|
||||
select,
|
||||
sendMessage,
|
||||
setNick,
|
||||
setServerName,
|
||||
toggleSearch,
|
||||
toggleUserList
|
||||
},
|
||||
dispatch
|
||||
),
|
||||
|
||||
inputActions: bindActionCreators(
|
||||
{
|
||||
add: addInputHistory,
|
||||
reset: resetInputHistory,
|
||||
increment: incrementInputHistory,
|
||||
decrement: decrementInputHistory
|
||||
},
|
||||
dispatch
|
||||
)
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(Chat);
|
@ -1,13 +1,14 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createStructuredSelector } from 'reselect';
|
||||
import Connect from 'components/pages/Connect';
|
||||
import { getConnectDefaults } from 'state/app';
|
||||
import { getConnectDefaults, getApp } from 'state/app';
|
||||
import { join } from 'state/channels';
|
||||
import { connect as connectServer } from 'state/servers';
|
||||
import { select } from 'state/tab';
|
||||
import connect from 'utils/connect';
|
||||
|
||||
const mapState = createStructuredSelector({
|
||||
defaults: getConnectDefaults
|
||||
defaults: getConnectDefaults,
|
||||
hexIP: state => getApp(state).hexIP
|
||||
});
|
||||
|
||||
const mapDispatch = {
|
||||
@ -16,4 +17,7 @@ const mapDispatch = {
|
||||
select
|
||||
};
|
||||
|
||||
export default connect(mapState, mapDispatch)(Connect);
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(Connect);
|
@ -1,9 +1,12 @@
|
||||
import { connect } from 'react-redux';
|
||||
import Navicon from 'components/ui/Navicon';
|
||||
import { toggleMenu } from 'state/ui';
|
||||
import connect from 'utils/connect';
|
||||
|
||||
const mapDispatch = {
|
||||
onClick: toggleMenu
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatch)(Navicon);
|
||||
export default connect(
|
||||
null,
|
||||
mapDispatch
|
||||
)(Navicon);
|
@ -1,5 +1,5 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createStructuredSelector } from 'reselect';
|
||||
import connect from 'utils/connect';
|
||||
|
||||
const Route = ({ route, name, children }) => {
|
||||
if (route === name) {
|
29
client/js/containers/Settings.js
Normal file
29
client/js/containers/Settings.js
Normal file
@ -0,0 +1,29 @@
|
||||
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
|
||||
});
|
||||
|
||||
const mapDispatch = {
|
||||
onCertChange: setCert,
|
||||
onKeyChange: setKey,
|
||||
uploadCert,
|
||||
setSetting,
|
||||
onInstall: () => appSet('installable', null)
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(Settings);
|
35
client/js/index.js
Normal file
35
client/js/index.js
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { createRoot } 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/fontello.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 });
|
||||
|
||||
createRoot(document.getElementById('root')).render(<Root store={store} />);
|
||||
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
e.preventDefault();
|
||||
store.dispatch(appSet('installable', e));
|
||||
});
|
||||
|
||||
register({
|
||||
onUpdate: () => store.dispatch(appSet('newVersionAvailable', true))
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { addMessages, inform, print } from 'state/messages';
|
||||
import { isChannel } from 'util';
|
||||
import { isChannel } from 'utils';
|
||||
|
||||
export const beforeHandler = '_before';
|
||||
export const notFoundHandler = 'commandNotFound';
|
@ -1,7 +1,7 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const debounceKey = action => {
|
||||
const key = action.socket.debounce.key;
|
||||
const { key } = action.socket.debounce;
|
||||
if (key) {
|
||||
return `${action.type} ${key}`;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { getRouter } from 'state';
|
||||
import { getCurrentServerName } from 'state/servers';
|
||||
import { observe } from 'util/observe';
|
||||
import { observe } from 'utils/observe';
|
||||
|
||||
export default function documentTitle({ store }) {
|
||||
observe(store, [getRouter, getCurrentServerName], (router, serverName) => {
|
@ -1,9 +1,9 @@
|
||||
import FontFaceObserver from 'fontfaceobserver';
|
||||
import { setCharWidth } from 'state/app';
|
||||
import { stringWidth } from 'util';
|
||||
import { stringWidth } from 'utils';
|
||||
|
||||
export default function fonts({ store }) {
|
||||
let charWidth = localStorage.charWidth;
|
||||
let { charWidth } = localStorage;
|
||||
if (charWidth) {
|
||||
store.dispatch(setCharWidth(parseFloat(charWidth)));
|
||||
}
|
@ -1,16 +1,18 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
import Cookie from 'js-cookie';
|
||||
import { socket as socketActions } from 'state/actions';
|
||||
import { getWrapWidth, setConnectDefaults } from 'state/app';
|
||||
import { getWrapWidth, setConnectDefaults, appSet } from 'state/app';
|
||||
import { addMessages } from 'state/messages';
|
||||
import { setSettings } from 'state/settings';
|
||||
import { select, updateSelection } from 'state/tab';
|
||||
import { find } from 'util';
|
||||
import { when } from 'util/observe';
|
||||
import { replace } from 'util/router';
|
||||
|
||||
export default function initialState({ store }) {
|
||||
const env = JSON.parse(document.getElementById('env').innerHTML);
|
||||
import { find } from 'utils';
|
||||
import { when } from 'utils/observe';
|
||||
import { replace } from 'utils/router';
|
||||
|
||||
function loadState({ store }, env) {
|
||||
store.dispatch(setConnectDefaults(env.defaults));
|
||||
store.dispatch(appSet('hexIP', env.hexIP));
|
||||
store.dispatch(setSettings(env.settings, true));
|
||||
|
||||
if (env.servers) {
|
||||
store.dispatch({
|
||||
@ -23,7 +25,13 @@ export default function initialState({ store }) {
|
||||
if (tab) {
|
||||
const [server, name = null] = tab.split(/;(.+)/);
|
||||
|
||||
if (name && find(env.channels, chan => chan.name === name)) {
|
||||
if (
|
||||
name &&
|
||||
find(
|
||||
env.channels,
|
||||
chan => chan.server === server && chan.name === name
|
||||
)
|
||||
) {
|
||||
store.dispatch(select(server, name, true));
|
||||
} else if (find(env.servers, srv => srv.host === server)) {
|
||||
store.dispatch(select(server, null, true));
|
||||
@ -61,3 +69,12 @@ export default function initialState({ store }) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function initialState(ctx) {
|
||||
if (window.__env__) {
|
||||
window.__env__.then(env => loadState(ctx, env));
|
||||
} else {
|
||||
const env = JSON.parse(document.getElementById('env').innerHTML);
|
||||
loadState(ctx, env);
|
||||
}
|
||||
}
|
@ -1,9 +1,15 @@
|
||||
import { socketAction } from 'state/actions';
|
||||
import { setConnected } from 'state/app';
|
||||
import { broadcast, inform, print, addMessage, addMessages } from 'state/messages';
|
||||
import {
|
||||
broadcast,
|
||||
inform,
|
||||
print,
|
||||
addMessage,
|
||||
addMessages
|
||||
} from 'state/messages';
|
||||
import { reconnect } from 'state/servers';
|
||||
import { select } from 'state/tab';
|
||||
import { normalizeChannel } from 'util';
|
||||
import { replace } from 'util/router';
|
||||
import { find, normalizeChannel } from 'utils';
|
||||
|
||||
function withReason(message, reason) {
|
||||
return message + (reason ? ` (${reason})` : '');
|
||||
@ -12,16 +18,19 @@ function withReason(message, reason) {
|
||||
function findChannels(state, server, user) {
|
||||
const channels = [];
|
||||
|
||||
state.channels.get(server).forEach((channel, channelName) => {
|
||||
if (channel.get('users').find(u => u.nick === user)) {
|
||||
channels.push(channelName);
|
||||
Object.keys(state.channels[server]).forEach(channel => {
|
||||
if (find(state.channels[server][channel].users, u => u.nick === user)) {
|
||||
channels.push(channel);
|
||||
}
|
||||
});
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
export default function handleSocket({ socket, store: { dispatch, getState } }) {
|
||||
export default function handleSocket({
|
||||
socket,
|
||||
store: { dispatch, getState }
|
||||
}) {
|
||||
const handlers = {
|
||||
message(message) {
|
||||
dispatch(addMessage(message, message.server, message.to));
|
||||
@ -40,11 +49,13 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
|
||||
const tab = state.tab.selected;
|
||||
const [joinedChannel] = channels;
|
||||
if (tab.server && tab.name) {
|
||||
const { nick } = state.servers.get(tab.server);
|
||||
if (tab.server === server &&
|
||||
const { nick } = state.servers[tab.server];
|
||||
if (
|
||||
tab.server === server &&
|
||||
nick === user &&
|
||||
tab.name !== joinedChannel &&
|
||||
normalizeChannel(tab.name) === normalizeChannel(joinedChannel)) {
|
||||
normalizeChannel(tab.name) === normalizeChannel(joinedChannel)
|
||||
) {
|
||||
dispatch(select(server, joinedChannel));
|
||||
}
|
||||
}
|
||||
@ -52,14 +63,10 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
|
||||
dispatch(inform(`${user} joined the channel`, server, joinedChannel));
|
||||
},
|
||||
|
||||
servers(data) {
|
||||
if (!data) {
|
||||
dispatch(replace('/connect'));
|
||||
}
|
||||
},
|
||||
|
||||
part({ user, server, channel, reason }) {
|
||||
dispatch(inform(withReason(`${user} left the channel`, reason), server, channel));
|
||||
dispatch(
|
||||
inform(withReason(`${user} left the channel`, reason), server, channel)
|
||||
);
|
||||
},
|
||||
|
||||
quit({ user, server, reason }) {
|
||||
@ -69,7 +76,9 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
|
||||
|
||||
nick({ server, oldNick, newNick }) {
|
||||
const channels = findChannels(getState(), server, oldNick);
|
||||
dispatch(broadcast(`${oldNick} changed nick to ${newNick}`, server, channels));
|
||||
dispatch(
|
||||
broadcast(`${oldNick} changed nick to ${newNick}`, server, channels)
|
||||
);
|
||||
},
|
||||
|
||||
topic({ server, channel, topic, nick }) {
|
||||
@ -90,14 +99,20 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
|
||||
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.server, tab.name));
|
||||
dispatch(
|
||||
print(
|
||||
[
|
||||
`Nick: ${data.nick}`,
|
||||
`Username: ${data.username}`,
|
||||
`Realname: ${data.realname}`,
|
||||
`Host: ${data.host}`,
|
||||
`Server: ${data.server}`,
|
||||
`Channels: ${data.channels}`
|
||||
],
|
||||
tab.server,
|
||||
tab.name
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
print(message) {
|
||||
@ -105,21 +120,42 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
|
||||
dispatch(addMessage(message, tab.server, tab.name));
|
||||
},
|
||||
|
||||
connection_update({ server, errorType }) {
|
||||
if (
|
||||
errorType === 'verify' &&
|
||||
window.confirm(
|
||||
'The server is using a self-signed certificate, continue anyway?'
|
||||
)
|
||||
) {
|
||||
dispatch(
|
||||
reconnect(server, {
|
||||
skipVerify: true
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
_connected(connected) {
|
||||
dispatch(setConnected(connected));
|
||||
}
|
||||
};
|
||||
|
||||
socket.onMessage((type, data) => {
|
||||
let action;
|
||||
if (Array.isArray(data)) {
|
||||
action = { type: socketAction(type), data: [...data] };
|
||||
} else {
|
||||
action = { ...data, type: socketAction(type) };
|
||||
}
|
||||
|
||||
if (type in handlers) {
|
||||
handlers[type](data);
|
||||
}
|
||||
|
||||
type = socketAction(type);
|
||||
if (Array.isArray(data)) {
|
||||
dispatch({ type, data });
|
||||
} else {
|
||||
dispatch({ type, ...data });
|
||||
if (type.charAt(0) === '_') {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(action);
|
||||
});
|
||||
}
|
18
client/js/modules/storage.js
Normal file
18
client/js/modules/storage.js
Normal file
@ -0,0 +1,18 @@
|
||||
import Cookie from 'js-cookie';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { getSelectedTab } from 'state/tab';
|
||||
import { isChannel, stringifyTab } from 'utils';
|
||||
import { observe } from 'utils/observe';
|
||||
|
||||
const saveTab = debounce(
|
||||
tab => Cookie.set('tab', stringifyTab(tab), { expires: 30 }),
|
||||
1000
|
||||
);
|
||||
|
||||
export default function storage({ store }) {
|
||||
observe(store, getSelectedTab, tab => {
|
||||
if (isChannel(tab) || (tab.server && !tab.name)) {
|
||||
saveTab(tab);
|
||||
}
|
||||
});
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import { getCharWidth } from 'state/app';
|
||||
import { updateMessageHeight } from 'state/messages';
|
||||
import { when } from 'util/observe';
|
||||
import { measureScrollBarWidth } from 'util';
|
||||
import { when } from 'utils/observe';
|
||||
import { measureScrollBarWidth } from 'utils';
|
||||
import { addResizeListener } from 'utils/size';
|
||||
|
||||
const menuWidth = 200;
|
||||
const messagePadding = 30;
|
||||
@ -13,8 +14,7 @@ export default function widthUpdates({ store }) {
|
||||
const scrollBarWidth = measureScrollBarWidth();
|
||||
let prevWrapWidth;
|
||||
|
||||
function updateWidth() {
|
||||
const windowWidth = window.innerWidth;
|
||||
function updateWidth(windowWidth) {
|
||||
let wrapWidth = windowWidth - scrollBarWidth - messagePadding;
|
||||
if (windowWidth > smallScreen) {
|
||||
wrapWidth -= menuWidth;
|
||||
@ -26,16 +26,6 @@ export default function widthUpdates({ store }) {
|
||||
}
|
||||
}
|
||||
|
||||
let resizeRAF;
|
||||
|
||||
function resize() {
|
||||
if (resizeRAF) {
|
||||
window.cancelAnimationFrame(resizeRAF);
|
||||
}
|
||||
resizeRAF = window.requestAnimationFrame(updateWidth);
|
||||
}
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener('resize', resize);
|
||||
addResizeListener(updateWidth, true);
|
||||
});
|
||||
}
|
90
client/js/serviceWorker.js
Normal file
90
client/js/serviceWorker.js
Normal file
@ -0,0 +1,90 @@
|
||||
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,4 +1,4 @@
|
||||
import { setServerName } from '../servers';
|
||||
import { connect, setServerName } from '../servers';
|
||||
|
||||
describe('setServerName()', () => {
|
||||
it('passes valid names to the server', () => {
|
@ -1,18 +1,19 @@
|
||||
import Immutable from 'immutable';
|
||||
import reducer from '../channels';
|
||||
import { connect } from '../servers';
|
||||
import reducer, { compareUsers, getSortedChannels } from '../channels';
|
||||
import { connect } from '../servers';
|
||||
import * as actions from '../actions';
|
||||
|
||||
describe('channel reducer', () => {
|
||||
it('removes channels on PART', () => {
|
||||
let state = Immutable.fromJS({
|
||||
let state = {
|
||||
srv1: {
|
||||
chan1: {}, chan2: {}, chan3: {}
|
||||
chan1: {},
|
||||
chan2: {},
|
||||
chan3: {}
|
||||
},
|
||||
srv2: {
|
||||
chan1: {}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.PART,
|
||||
@ -20,7 +21,7 @@ describe('channel reducer', () => {
|
||||
channels: ['chan1', 'chan3']
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv1: {
|
||||
chan2: {}
|
||||
},
|
||||
@ -42,17 +43,13 @@ describe('channel reducer', () => {
|
||||
user: 'nick2'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick1', renderName: 'nick1' },
|
||||
]
|
||||
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
|
||||
},
|
||||
chan2: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' }
|
||||
]
|
||||
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -61,12 +58,10 @@ describe('channel reducer', () => {
|
||||
it('handles SOCKET_JOIN', () => {
|
||||
const state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick1', renderName: 'nick1' }
|
||||
]
|
||||
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -83,12 +78,10 @@ describe('channel reducer', () => {
|
||||
user: 'nick2'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick1', renderName: 'nick1' }
|
||||
]
|
||||
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
|
||||
},
|
||||
chan2: {
|
||||
users: []
|
||||
@ -109,18 +102,16 @@ describe('channel reducer', () => {
|
||||
newNick: 'nick3'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' },
|
||||
{ mode: '', nick: 'nick3', renderName: 'nick3' }
|
||||
{ mode: '', nick: 'nick3', renderName: 'nick3' },
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' }
|
||||
]
|
||||
},
|
||||
chan2: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' }
|
||||
]
|
||||
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -131,28 +122,22 @@ describe('channel reducer', () => {
|
||||
type: actions.socket.USERS,
|
||||
server: 'srv',
|
||||
channel: 'chan1',
|
||||
users: [
|
||||
'user3',
|
||||
'user2',
|
||||
'@user4',
|
||||
'user1',
|
||||
'+user5'
|
||||
]
|
||||
users: ['user3', 'user2', '@user4', 'user1', '+user5']
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
{ mode: 'o', nick: 'user4', renderName: '@user4' },
|
||||
{ mode: 'v', nick: 'user5', renderName: '+user5' },
|
||||
{ mode: '', nick: 'user1', renderName: 'user1' },
|
||||
{ mode: '', nick: 'user3', renderName: 'user3' },
|
||||
{ mode: '', nick: 'user2', renderName: 'user2' },
|
||||
{ mode: '', nick: 'user3', renderName: 'user3' }
|
||||
{ mode: 'o', nick: 'user4', renderName: '@user4' },
|
||||
{ mode: '', nick: 'user1', renderName: 'user1' },
|
||||
{ mode: 'v', nick: 'user5', renderName: '+user5' }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('handles SOCKET_TOPIC', () => {
|
||||
@ -163,10 +148,11 @@ describe('channel reducer', () => {
|
||||
topic: 'the topic'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
topic: 'the topic'
|
||||
topic: 'the topic',
|
||||
users: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -179,7 +165,7 @@ describe('channel reducer', () => {
|
||||
|
||||
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', ''));
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
@ -188,29 +174,25 @@ describe('channel reducer', () => {
|
||||
]
|
||||
},
|
||||
chan2: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' }
|
||||
]
|
||||
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state = reducer(state, socket_mode('srv', 'chan1', 'nick1' ,'v', 'o'));
|
||||
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.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
{ mode: 'o', nick: 'nick2', renderName: '@nick2' },
|
||||
{ mode: 'v', nick: 'nick1', renderName: '+nick1' }
|
||||
{ mode: 'v', nick: 'nick1', renderName: '+nick1' },
|
||||
{ mode: 'o', nick: 'nick2', renderName: '@nick2' }
|
||||
]
|
||||
},
|
||||
chan2: {
|
||||
users: [
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' }
|
||||
]
|
||||
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -226,7 +208,7 @@ describe('channel reducer', () => {
|
||||
]
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: { topic: 'the topic', users: [] },
|
||||
chan2: { users: [] }
|
||||
@ -240,38 +222,38 @@ describe('channel reducer', () => {
|
||||
it('handles SOCKET_SERVERS', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: actions.socket.SERVERS,
|
||||
data: [
|
||||
{ host: '127.0.0.1' },
|
||||
{ host: 'thehost' }
|
||||
]
|
||||
data: [{ host: '127.0.0.1' }, { host: 'thehost' }]
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {},
|
||||
thehost: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('optimistically adds the server on CONNECT', () => {
|
||||
const state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
|
||||
const state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
);
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {}
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the server on DISCONNECT', () => {
|
||||
let state = Immutable.fromJS({
|
||||
let state = {
|
||||
srv: {},
|
||||
srv2: {}
|
||||
});
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv2'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {}
|
||||
});
|
||||
});
|
||||
@ -280,7 +262,8 @@ describe('channel reducer', () => {
|
||||
function socket_join(server, channel, user) {
|
||||
return {
|
||||
type: actions.socket.JOIN,
|
||||
server, user,
|
||||
server,
|
||||
user,
|
||||
channels: [channel]
|
||||
};
|
||||
}
|
||||
@ -288,6 +271,65 @@ function socket_join(server, channel, user) {
|
||||
function socket_mode(server, channel, user, add, remove) {
|
||||
return {
|
||||
type: actions.socket.MODE,
|
||||
server, channel, user, add, remove
|
||||
server,
|
||||
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 servers and channels', () => {
|
||||
expect(
|
||||
getSortedChannels({
|
||||
channels: {
|
||||
'bob.com': {},
|
||||
'127.0.0.1': {
|
||||
'#chan1': {
|
||||
users: [],
|
||||
topic: 'cake'
|
||||
},
|
||||
'#pie': {},
|
||||
'##apples': {}
|
||||
}
|
||||
}
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
channels: ['##apples', '#chan1', '#pie']
|
||||
},
|
||||
{
|
||||
address: 'bob.com',
|
||||
channels: []
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
import { Map, fromJS } from 'immutable';
|
||||
import reducer, { broadcast } from '../messages';
|
||||
import reducer, { broadcast, getMessageTab } from '../messages';
|
||||
import * as actions from '../actions';
|
||||
import appReducer from '../app';
|
||||
|
||||
@ -15,17 +14,19 @@ describe('message reducer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(state.toJS()).toMatchObject({
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
'#chan1': [{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
}]
|
||||
'#chan1': [
|
||||
{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds all the messsages on ADD_MESSAGES', () => {
|
||||
it('adds all the messages on ADD_MESSAGES', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: actions.ADD_MESSAGES,
|
||||
server: 'srv',
|
||||
@ -34,10 +35,12 @@ describe('message reducer', () => {
|
||||
{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
}, {
|
||||
},
|
||||
{
|
||||
from: 'bar',
|
||||
content: 'msg'
|
||||
}, {
|
||||
},
|
||||
{
|
||||
tab: '#chan2',
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
@ -45,31 +48,34 @@ describe('message reducer', () => {
|
||||
]
|
||||
});
|
||||
|
||||
expect(state.toJS()).toMatchObject({
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
'#chan1': [
|
||||
{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
}, {
|
||||
},
|
||||
{
|
||||
from: 'bar',
|
||||
content: 'msg'
|
||||
}
|
||||
],
|
||||
'#chan2': [{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
}]
|
||||
'#chan2': [
|
||||
{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('handles prepending of messages on ADD_MESSAGES', () => {
|
||||
let state = fromJS({
|
||||
let state = {
|
||||
srv: {
|
||||
'#chan1': [{ id: 0 }]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.ADD_MESSAGES,
|
||||
@ -79,7 +85,7 @@ describe('message reducer', () => {
|
||||
messages: [{ id: 1 }, { id: 2 }]
|
||||
});
|
||||
|
||||
expect(state.toJS()).toMatchObject({
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
'#chan1': [{ id: 1 }, { id: 2 }, { id: 0 }]
|
||||
}
|
||||
@ -93,11 +99,13 @@ describe('message reducer', () => {
|
||||
|
||||
const thunk = broadcast('test', 'srv', ['#chan1', '#chan3']);
|
||||
thunk(
|
||||
action => { state.messages = reducer(undefined, action); },
|
||||
action => {
|
||||
state.messages = reducer(undefined, action);
|
||||
},
|
||||
() => state
|
||||
);
|
||||
|
||||
const messages = state.messages.toJS();
|
||||
const messages = state.messages;
|
||||
|
||||
expect(messages.srv).not.toHaveProperty('srv');
|
||||
expect(messages.srv['#chan1']).toHaveLength(1);
|
||||
@ -107,54 +115,38 @@ describe('message reducer', () => {
|
||||
});
|
||||
|
||||
it('deletes all messages related to server when disconnecting', () => {
|
||||
let state = fromJS({
|
||||
let state = {
|
||||
srv: {
|
||||
'#chan1': [
|
||||
{ content: 'msg1' },
|
||||
{ content: 'msg2' }
|
||||
],
|
||||
'#chan2': [
|
||||
{ content: 'msg' }
|
||||
]
|
||||
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
|
||||
'#chan2': [{ content: 'msg' }]
|
||||
},
|
||||
srv2: {
|
||||
'#chan1': [
|
||||
{ content: 'msg' }
|
||||
]
|
||||
'#chan1': [{ content: 'msg' }]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv2: {
|
||||
'#chan1': [
|
||||
{ content: 'msg' }
|
||||
]
|
||||
'#chan1': [{ content: 'msg' }]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes all messages related to channel when parting', () => {
|
||||
let state = fromJS({
|
||||
let state = {
|
||||
srv: {
|
||||
'#chan1': [
|
||||
{ content: 'msg1' },
|
||||
{ content: 'msg2' }
|
||||
],
|
||||
'#chan2': [
|
||||
{ content: 'msg' }
|
||||
]
|
||||
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
|
||||
'#chan2': [{ content: 'msg' }]
|
||||
},
|
||||
srv2: {
|
||||
'#chan1': [
|
||||
{ content: 'msg' }
|
||||
]
|
||||
'#chan1': [{ content: 'msg' }]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.PART,
|
||||
@ -162,24 +154,18 @@ describe('message reducer', () => {
|
||||
channels: ['#chan1']
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
'#chan2': [
|
||||
{ content: 'msg' }
|
||||
]
|
||||
'#chan2': [{ content: 'msg' }]
|
||||
},
|
||||
srv2: {
|
||||
'#chan1': [
|
||||
{ content: 'msg' }
|
||||
]
|
||||
'#chan1': [{ content: 'msg' }]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessageTab()', () => {
|
||||
const getMessageTab = reducer.__get__('getMessageTab');
|
||||
|
||||
it('returns the correct tab', () => {
|
||||
const srv = 'chat.freenode.net';
|
||||
[
|
@ -1,77 +1,92 @@
|
||||
import Immutable from 'immutable';
|
||||
import reducer, { connect, setServerName } from '../servers';
|
||||
import * as actions from '../actions';
|
||||
|
||||
describe('server reducer', () => {
|
||||
it('adds the server on CONNECT', () => {
|
||||
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
);
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {
|
||||
connected: false,
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state = reducer(state, connect('127.0.0.1:1337', 'nick', {}));
|
||||
state = reducer(state, connect({ host: '127.0.0.1', nick: 'nick' }));
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {
|
||||
connected: false,
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state = reducer(state, connect('127.0.0.2:1337', 'nick', {
|
||||
name: 'srv'
|
||||
}));
|
||||
state = reducer(
|
||||
state,
|
||||
connect({ host: '127.0.0.2', nick: 'nick', name: 'srv' })
|
||||
);
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {
|
||||
connected: false,
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
},
|
||||
'127.0.0.2': {
|
||||
connected: false,
|
||||
name: 'srv',
|
||||
nick: 'nick',
|
||||
editedNick: null
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the server on DISCONNECT', () => {
|
||||
let state = Immutable.fromJS({
|
||||
let state = {
|
||||
srv: {},
|
||||
srv2: {}
|
||||
});
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv2'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('handles SET_SERVER_NAME', () => {
|
||||
let state = Immutable.fromJS({
|
||||
let state = {
|
||||
srv: {
|
||||
name: 'cake'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
state = reducer(state, setServerName('pie', 'srv'));
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
name: 'pie'
|
||||
}
|
||||
@ -79,7 +94,10 @@ describe('server reducer', () => {
|
||||
});
|
||||
|
||||
it('sets editedNick when editing the nick', () => {
|
||||
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.SET_NICK,
|
||||
server: '127.0.0.1',
|
||||
@ -87,9 +105,8 @@ describe('server reducer', () => {
|
||||
editing: true
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toMatchObject({
|
||||
'127.0.0.1': {
|
||||
connected: false,
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: 'nick2'
|
||||
@ -98,7 +115,10 @@ describe('server reducer', () => {
|
||||
});
|
||||
|
||||
it('clears editedNick when receiving an empty nick after editing finishes', () => {
|
||||
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.SET_NICK,
|
||||
server: '127.0.0.1',
|
||||
@ -111,9 +131,8 @@ describe('server reducer', () => {
|
||||
nick: ''
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toMatchObject({
|
||||
'127.0.0.1': {
|
||||
connected: false,
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null
|
||||
@ -122,7 +141,10 @@ describe('server reducer', () => {
|
||||
});
|
||||
|
||||
it('updates the nick on SOCKET_NICK', () => {
|
||||
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.socket.NICK,
|
||||
server: '127.0.0.1',
|
||||
@ -130,9 +152,8 @@ describe('server reducer', () => {
|
||||
newNick: 'nick2'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toMatchObject({
|
||||
'127.0.0.1': {
|
||||
connected: false,
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick2',
|
||||
editedNick: null
|
||||
@ -141,7 +162,10 @@ describe('server reducer', () => {
|
||||
});
|
||||
|
||||
it('clears editedNick on SOCKET_NICK_FAIL', () => {
|
||||
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.SET_NICK,
|
||||
server: '127.0.0.1',
|
||||
@ -153,9 +177,8 @@ describe('server reducer', () => {
|
||||
server: '127.0.0.1'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toMatchObject({
|
||||
'127.0.0.1': {
|
||||
connected: false,
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null
|
||||
@ -171,46 +194,79 @@ describe('server reducer', () => {
|
||||
host: '127.0.0.1',
|
||||
name: 'stuff',
|
||||
nick: 'nick',
|
||||
connected: true
|
||||
status: {
|
||||
connected: true
|
||||
}
|
||||
},
|
||||
{
|
||||
host: '127.0.0.2',
|
||||
name: 'stuffz',
|
||||
nick: 'nick2',
|
||||
connected: false
|
||||
},
|
||||
status: {
|
||||
connected: false
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {
|
||||
name: 'stuff',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
connected: true
|
||||
status: {
|
||||
connected: true
|
||||
}
|
||||
},
|
||||
'127.0.0.2': {
|
||||
name: 'stuffz',
|
||||
nick: 'nick2',
|
||||
editedNick: null,
|
||||
connected: false
|
||||
status: {
|
||||
connected: false
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('updates connection status on SOCKET_CONNECTION_UPDATE', () => {
|
||||
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.socket.CONNECTION_UPDATE,
|
||||
'127.0.0.1': true
|
||||
server: '127.0.0.1',
|
||||
connected: true
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
connected: true
|
||||
status: {
|
||||
connected: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.socket.CONNECTION_UPDATE,
|
||||
server: '127.0.0.1',
|
||||
connected: false,
|
||||
error: 'Bad stuff happened'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
'127.0.0.1': {
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: 'Bad stuff happened'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
@ -1,21 +1,19 @@
|
||||
import reducer, { setSelectedTab } from '../tab';
|
||||
import * as actions from '../actions';
|
||||
import { locationChanged } from '../../util/router';
|
||||
import { locationChanged } from 'utils/router';
|
||||
|
||||
describe('tab reducer', () => {
|
||||
it('selects the tab and adds it to history', () => {
|
||||
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan' },
|
||||
history: [
|
||||
{ server: 'srv', name: '#chan' }
|
||||
]
|
||||
history: [{ server: 'srv', name: '#chan' }]
|
||||
});
|
||||
|
||||
state = reducer(state, setSelectedTab('srv', 'user1'));
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: 'user1' },
|
||||
history: [
|
||||
{ server: 'srv', name: '#chan' },
|
||||
@ -36,7 +34,7 @@ describe('tab reducer', () => {
|
||||
channels: ['#chan']
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan3' },
|
||||
history: [
|
||||
{ server: 'srv1', name: 'bob' },
|
||||
@ -57,12 +55,12 @@ describe('tab reducer', () => {
|
||||
nick: 'bob'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan3' },
|
||||
history: [
|
||||
{ server: 'srv', name: '#chan' },
|
||||
{ server: 'srv', name: '#chan' },
|
||||
{ server: 'srv', name: '#chan3' }
|
||||
{ server: 'srv', name: '#chan3' }
|
||||
]
|
||||
});
|
||||
});
|
||||
@ -75,14 +73,12 @@ describe('tab reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv',
|
||||
server: 'srv'
|
||||
});
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan3' },
|
||||
history: [
|
||||
{ server: 'srv1', name: 'bob' },
|
||||
]
|
||||
history: [{ server: 'srv1', name: 'bob' }]
|
||||
});
|
||||
});
|
||||
|
||||
@ -91,27 +87,24 @@ describe('tab reducer', () => {
|
||||
|
||||
state = reducer(state, locationChanged('settings'));
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
selected: { server: null, name: null },
|
||||
history: [
|
||||
{ server: 'srv', name: '#chan' }
|
||||
]
|
||||
expect(state).toEqual({
|
||||
selected: {},
|
||||
history: [{ server: 'srv', name: '#chan' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('selects the tab and adds it to history when navigating to a tab', () => {
|
||||
const state = reducer(undefined,
|
||||
const state = reducer(
|
||||
undefined,
|
||||
locationChanged('chat', {
|
||||
server: 'srv',
|
||||
name: '#chan'
|
||||
})
|
||||
);
|
||||
|
||||
expect(state.toJS()).toEqual({
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan' },
|
||||
history: [
|
||||
{ server: 'srv', name: '#chan' }
|
||||
]
|
||||
history: [{ server: 'srv', name: '#chan' }]
|
||||
});
|
||||
});
|
||||
});
|
@ -28,6 +28,7 @@ export const TOGGLE_SEARCH = 'TOGGLE_SEARCH';
|
||||
export const AWAY = 'AWAY';
|
||||
export const CONNECT = 'CONNECT';
|
||||
export const DISCONNECT = 'DISCONNECT';
|
||||
export const RECONNECT = 'RECONNECT';
|
||||
export const SET_NICK = 'SET_NICK';
|
||||
export const SET_SERVER_NAME = 'SET_SERVER_NAME';
|
||||
export const WHOIS = 'WHOIS';
|
||||
@ -36,6 +37,7 @@ export const SET_CERT = 'SET_CERT';
|
||||
export const SET_CERT_ERROR = 'SET_CERT_ERROR';
|
||||
export const SET_KEY = 'SET_KEY';
|
||||
export const UPLOAD_CERT = 'UPLOAD_CERT';
|
||||
export const SETTINGS_SET = 'SETTINGS_SET';
|
||||
|
||||
export const SELECT_TAB = 'SELECT_TAB';
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Record } from 'immutable';
|
||||
import createReducer from 'util/createReducer';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import * as actions from './actions';
|
||||
|
||||
export const getApp = state => state.app;
|
||||
@ -9,32 +8,34 @@ export const getCharWidth = state => state.app.charWidth;
|
||||
export const getWindowWidth = state => state.app.windowWidth;
|
||||
export const getConnectDefaults = state => state.app.connectDefaults;
|
||||
|
||||
const ConnectDefaults = Record({
|
||||
name: '',
|
||||
address: '',
|
||||
channels: [],
|
||||
ssl: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const App = Record({
|
||||
const initialState = {
|
||||
connected: true,
|
||||
wrapWidth: 0,
|
||||
charWidth: 0,
|
||||
windowWidth: 0,
|
||||
connectDefaults: new ConnectDefaults()
|
||||
});
|
||||
connectDefaults: {
|
||||
name: '',
|
||||
address: '',
|
||||
channels: [],
|
||||
ssl: false,
|
||||
password: false,
|
||||
readonly: false,
|
||||
showDetails: false
|
||||
},
|
||||
hexIP: false,
|
||||
newVersionAvailable: false,
|
||||
installable: null
|
||||
};
|
||||
|
||||
export default createReducer(new App(), {
|
||||
[actions.APP_SET](state, action) {
|
||||
return state.set(action.key, action.value);
|
||||
export default createReducer(initialState, {
|
||||
[actions.APP_SET](state, { key, value }) {
|
||||
state[key] = value;
|
||||
},
|
||||
|
||||
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
|
||||
return state
|
||||
.set('wrapWidth', action.wrapWidth)
|
||||
.set('charWidth', action.charWidth)
|
||||
.set('windowWidth', action.windowWidth);
|
||||
state.wrapWidth = action.wrapWidth;
|
||||
state.charWidth = action.charWidth;
|
||||
state.windowWidth = action.windowWidth;
|
||||
}
|
||||
});
|
||||
|
||||
@ -55,5 +56,5 @@ export function setCharWidth(width) {
|
||||
}
|
||||
|
||||
export function setConnectDefaults(defaults) {
|
||||
return appSet('connectDefaults', new ConnectDefaults(defaults));
|
||||
return appSet('connectDefaults', defaults);
|
||||
}
|
277
client/js/state/channels.js
Normal file
277
client/js/state/channels.js
Normal file
@ -0,0 +1,277 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import get from 'lodash/get';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { 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, server, channel) {
|
||||
if (!state[server]) {
|
||||
state[server] = {};
|
||||
}
|
||||
if (channel && !state[server][channel]) {
|
||||
state[server][channel] = { users: [] };
|
||||
}
|
||||
}
|
||||
|
||||
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(server => ({
|
||||
address: server,
|
||||
channels: sortBy(Object.keys(channels[server]), channel =>
|
||||
channel.toLowerCase()
|
||||
)
|
||||
})),
|
||||
server => server.address.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
export const getSelectedChannel = createSelector(
|
||||
getSelectedTab,
|
||||
getChannels,
|
||||
(tab, channels) => get(channels, [tab.server, tab.name])
|
||||
);
|
||||
|
||||
export const getSelectedChannelUsers = createSelector(
|
||||
getSelectedChannel,
|
||||
channel => {
|
||||
if (channel) {
|
||||
return channel.users.concat().sort(compareUsers);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
);
|
||||
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.PART](state, { server, channels }) {
|
||||
channels.forEach(channel => delete state[server][channel]);
|
||||
},
|
||||
|
||||
[actions.socket.JOIN](state, { server, channels, user }) {
|
||||
const channel = channels[0];
|
||||
init(state, server, channel);
|
||||
state[server][channel].users.push(createUser(user));
|
||||
},
|
||||
|
||||
[actions.socket.PART](state, { server, channel, user }) {
|
||||
if (state[server][channel]) {
|
||||
removeUser(state[server][channel].users, user);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.QUIT](state, { server, user }) {
|
||||
Object.keys(state[server]).forEach(channel => {
|
||||
removeUser(state[server][channel].users, user);
|
||||
});
|
||||
},
|
||||
|
||||
[actions.socket.NICK](state, { server, oldNick, newNick }) {
|
||||
Object.keys(state[server]).forEach(channel => {
|
||||
const user = find(
|
||||
state[server][channel].users,
|
||||
u => u.nick === oldNick
|
||||
);
|
||||
if (user) {
|
||||
user.nick = newNick;
|
||||
user.renderName = getRenderName(user);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
[actions.socket.USERS](state, { server, channel, users }) {
|
||||
init(state, server, channel);
|
||||
state[server][channel].users = users.map(nick => loadUser(nick));
|
||||
},
|
||||
|
||||
[actions.socket.TOPIC](state, { server, channel, topic }) {
|
||||
init(state, server, channel);
|
||||
state[server][channel].topic = topic;
|
||||
},
|
||||
|
||||
[actions.socket.MODE](state, { server, channel, user, remove, add }) {
|
||||
const u = find(state[server][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.CHANNELS](state, { data }) {
|
||||
if (data) {
|
||||
data.forEach(({ server, name, topic }) => {
|
||||
init(state, server, name);
|
||||
state[server][name].topic = topic;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.SERVERS](state, { data }) {
|
||||
if (data) {
|
||||
data.forEach(({ host }) => init(state, host));
|
||||
}
|
||||
},
|
||||
|
||||
[actions.CONNECT](state, { host }) {
|
||||
init(state, host);
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
delete state[server];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function join(channels, server) {
|
||||
return {
|
||||
type: actions.JOIN,
|
||||
channels,
|
||||
server,
|
||||
socket: {
|
||||
type: 'join',
|
||||
data: { channels, server }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function part(channels, server) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: actions.PART,
|
||||
channels,
|
||||
server,
|
||||
socket: {
|
||||
type: 'part',
|
||||
data: { channels, server }
|
||||
}
|
||||
});
|
||||
dispatch(updateSelection());
|
||||
};
|
||||
}
|
||||
|
||||
export function invite(user, channel, server) {
|
||||
return {
|
||||
type: actions.INVITE,
|
||||
user,
|
||||
channel,
|
||||
server,
|
||||
socket: {
|
||||
type: 'invite',
|
||||
data: { user, channel, server }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function kick(user, channel, server) {
|
||||
return {
|
||||
type: actions.KICK,
|
||||
user,
|
||||
channel,
|
||||
server,
|
||||
socket: {
|
||||
type: 'kick',
|
||||
data: { user, channel, server }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setTopic(topic, channel, server) {
|
||||
return {
|
||||
type: actions.SET_TOPIC,
|
||||
topic,
|
||||
channel,
|
||||
server,
|
||||
socket: {
|
||||
type: 'topic',
|
||||
data: { topic, channel, server }
|
||||
}
|
||||
};
|
||||
}
|
@ -1,54 +1,45 @@
|
||||
import { List, Record } from 'immutable';
|
||||
import createReducer from 'util/createReducer';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import * as actions from './actions';
|
||||
|
||||
const HISTORY_MAX_LENGTH = 128;
|
||||
|
||||
const State = Record({
|
||||
history: List(),
|
||||
const initialState = {
|
||||
history: [],
|
||||
index: 0
|
||||
});
|
||||
};
|
||||
|
||||
export const getCurrentInputHistoryEntry = state => {
|
||||
if (state.input.index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return state.input.history.get(state.input.index);
|
||||
return state.input.history[state.input.index];
|
||||
};
|
||||
|
||||
export default createReducer(new State(), {
|
||||
[actions.INPUT_HISTORY_ADD](state, action) {
|
||||
const { line } = action;
|
||||
if (line.trim() && line !== state.history.get(0)) {
|
||||
if (history.length === HISTORY_MAX_LENGTH) {
|
||||
return state.set('history', state.history.unshift(line).pop());
|
||||
export default createReducer(initialState, {
|
||||
[actions.INPUT_HISTORY_ADD](state, { line }) {
|
||||
if (line.trim() && line !== state.history[0]) {
|
||||
if (state.history.length === HISTORY_MAX_LENGTH) {
|
||||
state.history.pop();
|
||||
}
|
||||
|
||||
return state.set('history', state.history.unshift(line));
|
||||
state.history.unshift(line);
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
|
||||
[actions.INPUT_HISTORY_RESET](state) {
|
||||
return state.set('index', -1);
|
||||
state.index = -1;
|
||||
},
|
||||
|
||||
[actions.INPUT_HISTORY_INCREMENT](state) {
|
||||
if (state.index < state.history.size - 1) {
|
||||
return state.set('index', state.index + 1);
|
||||
if (state.index < state.history.length - 1) {
|
||||
state.index++;
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
|
||||
[actions.INPUT_HISTORY_DECREMENT](state) {
|
||||
if (state.index >= 0) {
|
||||
return state.set('index', state.index - 1);
|
||||
state.index--;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
});
|
||||
|
@ -1,83 +1,104 @@
|
||||
import { List, Map, Record } from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
import { findBreakpoints, messageHeight, linkify, timestamp, isChannel } from 'util';
|
||||
import createReducer from 'util/createReducer';
|
||||
import has from 'lodash/has';
|
||||
import {
|
||||
findBreakpoints,
|
||||
messageHeight,
|
||||
linkify,
|
||||
timestamp,
|
||||
isChannel
|
||||
} from 'utils';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { getApp } from './app';
|
||||
import { getSelectedTab } from './tab';
|
||||
import * as actions from './actions';
|
||||
|
||||
const Message = Record({
|
||||
id: null,
|
||||
from: null,
|
||||
content: '',
|
||||
time: null,
|
||||
type: null,
|
||||
channel: false,
|
||||
next: false,
|
||||
height: 0,
|
||||
length: 0,
|
||||
breakpoints: null
|
||||
});
|
||||
|
||||
export const getMessages = state => state.messages;
|
||||
|
||||
export const getSelectedMessages = createSelector(
|
||||
getSelectedTab,
|
||||
getMessages,
|
||||
(tab, messages) => messages.getIn([tab.server, tab.name || tab.server], List())
|
||||
(tab, messages) => {
|
||||
const target = tab.name || tab.server;
|
||||
if (has(messages, [tab.server, target])) {
|
||||
return messages[tab.server][target];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
);
|
||||
|
||||
export const getHasMoreMessages = createSelector(
|
||||
getSelectedMessages,
|
||||
messages => {
|
||||
const first = messages.get(0);
|
||||
const first = messages[0];
|
||||
return first && first.next;
|
||||
}
|
||||
);
|
||||
|
||||
export default createReducer(Map(), {
|
||||
[actions.ADD_MESSAGE](state, { server, tab, message }) {
|
||||
return state.updateIn([server, tab], List(), list => list.push(new Message(message)));
|
||||
},
|
||||
|
||||
[actions.ADD_MESSAGES](state, { server, tab, messages, prepend }) {
|
||||
return state.withMutations(s => {
|
||||
if (prepend) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
s.updateIn([server, tab], List(), list => list.unshift(new Message(messages[i])));
|
||||
}
|
||||
} else {
|
||||
messages.forEach(message =>
|
||||
s.updateIn([server, message.tab || tab], List(), list => list.push(new Message(message)))
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
return state.delete(server);
|
||||
},
|
||||
|
||||
[actions.PART](state, { server, channels }) {
|
||||
return state.withMutations(s =>
|
||||
channels.forEach(channel =>
|
||||
s.deleteIn([server, channel])
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
[actions.UPDATE_MESSAGE_HEIGHT](state, { wrapWidth, charWidth, windowWidth }) {
|
||||
return state.withMutations(s =>
|
||||
s.forEach((server, serverKey) =>
|
||||
server.forEach((target, targetKey) =>
|
||||
target.forEach((message, index) => s.setIn([serverKey, targetKey, index, 'height'],
|
||||
messageHeight(message, wrapWidth, charWidth, 6 * charWidth, windowWidth))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
function init(state, server, tab) {
|
||||
if (!state[server]) {
|
||||
state[server] = {};
|
||||
}
|
||||
});
|
||||
if (!state[server][tab]) {
|
||||
state[server][tab] = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.ADD_MESSAGE](state, { server, tab, message }) {
|
||||
init(state, server, tab);
|
||||
state[server][tab].push(message);
|
||||
},
|
||||
|
||||
[actions.ADD_MESSAGES](state, { server, tab, messages, prepend }) {
|
||||
if (prepend) {
|
||||
init(state, server, tab);
|
||||
state[server][tab].unshift(...messages);
|
||||
} else {
|
||||
messages.forEach(message => {
|
||||
init(state, server, message.tab || tab);
|
||||
state[server][message.tab || tab].push(message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
delete state[server];
|
||||
},
|
||||
|
||||
[actions.PART](state, { server, channels }) {
|
||||
channels.forEach(channel => delete state[server][channel]);
|
||||
},
|
||||
|
||||
[actions.UPDATE_MESSAGE_HEIGHT](
|
||||
state,
|
||||
{ wrapWidth, charWidth, windowWidth }
|
||||
) {
|
||||
Object.keys(state).forEach(server =>
|
||||
Object.keys(state[server]).forEach(target =>
|
||||
state[server][target].forEach(message => {
|
||||
message.height = messageHeight(
|
||||
message,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
6 * charWidth,
|
||||
windowWidth
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
[actions.socket.SERVERS](state, { data }) {
|
||||
if (data) {
|
||||
data.forEach(({ host }) => {
|
||||
state[host] = {};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let nextID = 0;
|
||||
|
||||
@ -101,7 +122,7 @@ function initMessage(message, tab, state) {
|
||||
message.content = message.content.replace(/\s\s+/g, ' ');
|
||||
|
||||
if (message.content.indexOf('\x01ACTION') === 0) {
|
||||
const from = message.from;
|
||||
const { from } = message;
|
||||
message.from = null;
|
||||
message.type = 'action';
|
||||
message.content = from + message.content.slice(7, -1);
|
||||
@ -111,13 +132,19 @@ function initMessage(message, tab, state) {
|
||||
|
||||
message.length = message.content.length;
|
||||
message.breakpoints = findBreakpoints(message.content);
|
||||
message.height = messageHeight(message, wrapWidth, charWidth, 6 * charWidth, windowWidth);
|
||||
message.height = messageHeight(
|
||||
message,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
6 * charWidth,
|
||||
windowWidth
|
||||
);
|
||||
message.content = linkify(message.content);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function getMessageTab(server, to) {
|
||||
export function getMessageTab(server, to) {
|
||||
if (!to || to === '*' || (!isChannel(to) && to.indexOf('.') !== -1)) {
|
||||
return server;
|
||||
}
|
||||
@ -127,14 +154,14 @@ function getMessageTab(server, to) {
|
||||
export function fetchMessages() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const first = getSelectedMessages(state).get(0);
|
||||
const first = getSelectedMessages(state)[0];
|
||||
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = state.tab.selected;
|
||||
if (tab.isChannel()) {
|
||||
if (isChannel(tab)) {
|
||||
dispatch({
|
||||
type: actions.FETCH_MESSAGES,
|
||||
socket: {
|
||||
@ -175,10 +202,14 @@ export function sendMessage(content, to, server) {
|
||||
type: actions.ADD_MESSAGE,
|
||||
server,
|
||||
tab: to,
|
||||
message: initMessage({
|
||||
from: state.servers.getIn([server, 'nick']),
|
||||
content
|
||||
}, to, state),
|
||||
message: initMessage(
|
||||
{
|
||||
from: state.servers[server].nick,
|
||||
content
|
||||
},
|
||||
to,
|
||||
state
|
||||
),
|
||||
socket: {
|
||||
type: 'message',
|
||||
data: { content, to, server }
|
||||
@ -190,12 +221,13 @@ export function sendMessage(content, to, server) {
|
||||
export function addMessage(message, server, to) {
|
||||
const tab = getMessageTab(server, to);
|
||||
|
||||
return (dispatch, getState) => dispatch({
|
||||
type: actions.ADD_MESSAGE,
|
||||
server,
|
||||
tab,
|
||||
message: initMessage(message, tab, getState())
|
||||
});
|
||||
return (dispatch, getState) =>
|
||||
dispatch({
|
||||
type: actions.ADD_MESSAGE,
|
||||
server,
|
||||
tab,
|
||||
message: initMessage(message, tab, getState())
|
||||
});
|
||||
}
|
||||
|
||||
export function addMessages(messages, server, to, prepend, next) {
|
||||
@ -209,7 +241,9 @@ export function addMessages(messages, server, to, prepend, next) {
|
||||
messages[0].next = true;
|
||||
}
|
||||
|
||||
messages.forEach(message => initMessage(message, message.tab || tab, state));
|
||||
messages.forEach(message =>
|
||||
initMessage(message, message.tab || tab, state)
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: actions.ADD_MESSAGES,
|
||||
@ -222,25 +256,36 @@ export function addMessages(messages, server, to, prepend, next) {
|
||||
}
|
||||
|
||||
export function broadcast(message, server, channels) {
|
||||
return addMessages(channels.map(channel => ({
|
||||
tab: channel,
|
||||
content: message,
|
||||
type: 'info'
|
||||
})), server);
|
||||
return addMessages(
|
||||
channels.map(channel => ({
|
||||
tab: channel,
|
||||
content: message,
|
||||
type: 'info'
|
||||
})),
|
||||
server
|
||||
);
|
||||
}
|
||||
|
||||
export function print(message, server, channel, type) {
|
||||
if (Array.isArray(message)) {
|
||||
return addMessages(message.map(line => ({
|
||||
content: line,
|
||||
type
|
||||
})), server, channel);
|
||||
return addMessages(
|
||||
message.map(line => ({
|
||||
content: line,
|
||||
type
|
||||
})),
|
||||
server,
|
||||
channel
|
||||
);
|
||||
}
|
||||
|
||||
return addMessage({
|
||||
content: message,
|
||||
type
|
||||
}, server, channel);
|
||||
return addMessage(
|
||||
{
|
||||
content: message,
|
||||
type
|
||||
},
|
||||
server,
|
||||
channel
|
||||
);
|
||||
}
|
||||
|
||||
export function inform(message, server, channel) {
|
62
client/js/state/privateChats.js
Normal file
62
client/js/state/privateChats.js
Normal file
@ -0,0 +1,62 @@
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { findIndex } 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, server, nick) {
|
||||
if (!state[server]) {
|
||||
state[server] = [];
|
||||
}
|
||||
if (findIndex(state[server], n => n === nick) === -1) {
|
||||
state[server].push(nick);
|
||||
state[server] = sortBy(state[server], v => v.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.OPEN_PRIVATE_CHAT](state, action) {
|
||||
open(state, action.server, action.nick);
|
||||
},
|
||||
|
||||
[actions.CLOSE_PRIVATE_CHAT](state, { server, nick }) {
|
||||
const i = findIndex(state[server], n => n === nick);
|
||||
if (i !== -1) {
|
||||
state[server].splice(i, 1);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.PM](state, action) {
|
||||
if (action.from.indexOf('.') === -1) {
|
||||
open(state, action.server, action.from);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
delete state[server];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function openPrivateChat(server, nick) {
|
||||
return {
|
||||
type: actions.OPEN_PRIVATE_CHAT,
|
||||
server,
|
||||
nick
|
||||
};
|
||||
}
|
||||
|
||||
export function closePrivateChat(server, nick) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: actions.CLOSE_PRIVATE_CHAT,
|
||||
server,
|
||||
nick
|
||||
});
|
||||
dispatch(updateSelection());
|
||||
};
|
||||
}
|
@ -1,21 +1,20 @@
|
||||
import { List, Record } from 'immutable';
|
||||
import createReducer from 'util/createReducer';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import * as actions from './actions';
|
||||
|
||||
const State = Record({
|
||||
const initialState = {
|
||||
show: false,
|
||||
results: List()
|
||||
});
|
||||
results: []
|
||||
};
|
||||
|
||||
export const getSearch = state => state.search;
|
||||
|
||||
export default createReducer(new State(), {
|
||||
[actions.socket.SEARCH](state, action) {
|
||||
return state.set('results', List(action.results));
|
||||
export default createReducer(initialState, {
|
||||
[actions.socket.SEARCH](state, { results }) {
|
||||
state.results = results || [];
|
||||
},
|
||||
|
||||
[actions.TOGGLE_SEARCH](state) {
|
||||
return state.set('show', !state.show);
|
||||
state.show = !state.show;
|
||||
}
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import get from 'lodash/get';
|
||||
import { getServers } from './servers';
|
||||
import { getSelectedTab } from './tab';
|
||||
|
||||
@ -6,5 +7,5 @@ import { getSelectedTab } from './tab';
|
||||
export const getSelectedTabTitle = createSelector(
|
||||
getSelectedTab,
|
||||
getServers,
|
||||
(tab, servers) => tab.name || servers.getIn([tab.server, 'name'])
|
||||
(tab, servers) => tab.name || get(servers, [tab.server, 'name'])
|
||||
);
|
210
client/js/state/servers.js
Normal file
210
client/js/state/servers.js
Normal file
@ -0,0 +1,210 @@
|
||||
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 getServers = state => state.servers;
|
||||
|
||||
export const getCurrentNick = createSelector(
|
||||
getServers,
|
||||
getSelectedTab,
|
||||
(servers, tab) => {
|
||||
if (!servers[tab.server]) {
|
||||
return;
|
||||
}
|
||||
const { editedNick } = servers[tab.server];
|
||||
if (editedNick === null) {
|
||||
return servers[tab.server].nick;
|
||||
}
|
||||
return editedNick;
|
||||
}
|
||||
);
|
||||
|
||||
export const getCurrentServerName = createSelector(
|
||||
getServers,
|
||||
getSelectedTab,
|
||||
(servers, tab) => get(servers, [tab.server, 'name'])
|
||||
);
|
||||
|
||||
export const getCurrentServerStatus = createSelector(
|
||||
getServers,
|
||||
getSelectedTab,
|
||||
(servers, tab) => get(servers, [tab.server, 'status'], {})
|
||||
);
|
||||
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.CONNECT](state, { host, nick, name }) {
|
||||
if (!state[host]) {
|
||||
state[host] = {
|
||||
nick,
|
||||
editedNick: null,
|
||||
name: name || host,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
delete state[server];
|
||||
},
|
||||
|
||||
[actions.SET_SERVER_NAME](state, { server, name }) {
|
||||
state[server].name = name;
|
||||
},
|
||||
|
||||
[actions.SET_NICK](state, { server, nick, editing }) {
|
||||
if (editing) {
|
||||
state[server].editedNick = nick;
|
||||
} else if (nick === '') {
|
||||
state[server].editedNick = null;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.NICK](state, { server, oldNick, newNick }) {
|
||||
if (!oldNick || oldNick === state[server].nick) {
|
||||
state[server].nick = newNick;
|
||||
state[server].editedNick = null;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.NICK_FAIL](state, { server }) {
|
||||
state[server].editedNick = null;
|
||||
},
|
||||
|
||||
[actions.socket.SERVERS](state, { data }) {
|
||||
if (data) {
|
||||
data.forEach(({ host, name, nick, status }) => {
|
||||
state[host] = { name, nick, status, editedNick: null };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.CONNECTION_UPDATE](state, { server, connected, error }) {
|
||||
if (state[server]) {
|
||||
state[server].status.connected = connected;
|
||||
state[server].status.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function connect(config) {
|
||||
return {
|
||||
type: actions.CONNECT,
|
||||
...config,
|
||||
socket: {
|
||||
type: 'connect',
|
||||
data: config
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnect(server) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: actions.DISCONNECT,
|
||||
server,
|
||||
socket: {
|
||||
type: 'quit',
|
||||
data: { server }
|
||||
}
|
||||
});
|
||||
dispatch(updateSelection());
|
||||
};
|
||||
}
|
||||
|
||||
export function reconnect(server, settings) {
|
||||
return {
|
||||
type: actions.RECONNECT,
|
||||
server,
|
||||
settings,
|
||||
socket: {
|
||||
type: 'reconnect',
|
||||
data: {
|
||||
...settings,
|
||||
server
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function whois(user, server) {
|
||||
return {
|
||||
type: actions.WHOIS,
|
||||
user,
|
||||
server,
|
||||
socket: {
|
||||
type: 'whois',
|
||||
data: { user, server }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function away(message, server) {
|
||||
return {
|
||||
type: actions.AWAY,
|
||||
message,
|
||||
server,
|
||||
socket: {
|
||||
type: 'away',
|
||||
data: { message, server }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setNick(nick, server, editing) {
|
||||
nick = nick.trim().replace(' ', '');
|
||||
|
||||
const action = {
|
||||
type: actions.SET_NICK,
|
||||
nick,
|
||||
server,
|
||||
editing
|
||||
};
|
||||
|
||||
if (!editing && nick !== '') {
|
||||
action.socket = {
|
||||
type: 'nick',
|
||||
data: {
|
||||
newNick: nick,
|
||||
server
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
export function isValidServerName(name) {
|
||||
return name.trim() !== '';
|
||||
}
|
||||
|
||||
export function setServerName(name, server) {
|
||||
const action = {
|
||||
type: actions.SET_SERVER_NAME,
|
||||
name,
|
||||
server
|
||||
};
|
||||
|
||||
if (isValidServerName(name)) {
|
||||
action.socket = {
|
||||
type: 'set_server_name',
|
||||
data: {
|
||||
name,
|
||||
server
|
||||
},
|
||||
debounce: {
|
||||
delay: 500,
|
||||
key: `server_name:${server}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
127
client/js/state/settings.js
Normal file
127
client/js/state/settings.js
Normal file
@ -0,0 +1,127 @@
|
||||
import assign from 'lodash/assign';
|
||||
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) {
|
||||
assign(state, settings);
|
||||
} else {
|
||||
state[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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: cert
|
||||
};
|
||||
}
|
||||
|
||||
export function setKey(fileName, key) {
|
||||
return {
|
||||
type: actions.SET_KEY,
|
||||
fileName,
|
||||
key: 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;
|
||||
}
|
84
client/js/state/tab.js
Normal file
84
client/js/state/tab.js
Normal file
@ -0,0 +1,84 @@
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { push, replace, LOCATION_CHANGED } from 'utils/router';
|
||||
import * as actions from './actions';
|
||||
|
||||
const initialState = {
|
||||
selected: {},
|
||||
history: []
|
||||
};
|
||||
|
||||
function selectTab(state, action) {
|
||||
state.selected = {
|
||||
server: action.server,
|
||||
name: action.name
|
||||
};
|
||||
state.history.push(state.selected);
|
||||
}
|
||||
|
||||
export const getSelectedTab = state => state.tab.selected;
|
||||
|
||||
export default createReducer(initialState, {
|
||||
[actions.SELECT_TAB]: selectTab,
|
||||
|
||||
[actions.PART](state, action) {
|
||||
state.history = state.history.filter(
|
||||
tab => !(tab.server === action.server && tab.name === action.channels[0])
|
||||
);
|
||||
},
|
||||
|
||||
[actions.CLOSE_PRIVATE_CHAT](state, action) {
|
||||
state.history = state.history.filter(
|
||||
tab => !(tab.server === action.server && tab.name === action.nick)
|
||||
);
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, action) {
|
||||
state.history = state.history.filter(tab => tab.server !== action.server);
|
||||
},
|
||||
|
||||
[LOCATION_CHANGED](state, action) {
|
||||
const { route, params } = action;
|
||||
if (route === 'chat') {
|
||||
selectTab(state, params);
|
||||
} else {
|
||||
state.selected = {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function select(server, name, doReplace) {
|
||||
const navigate = doReplace ? replace : push;
|
||||
if (name) {
|
||||
return navigate(`/${server}/${encodeURIComponent(name)}`);
|
||||
}
|
||||
return navigate(`/${server}`);
|
||||
}
|
||||
|
||||
export function updateSelection() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const { history } = state.tab;
|
||||
const { servers } = state;
|
||||
const { server } = state.tab.selected;
|
||||
const serverAddrs = Object.keys(servers);
|
||||
|
||||
if (serverAddrs.length === 0) {
|
||||
dispatch(replace('/connect'));
|
||||
} else if (history.length > 0) {
|
||||
const tab = history[history.length - 1];
|
||||
dispatch(select(tab.server, tab.name, true));
|
||||
} else if (servers[server]) {
|
||||
dispatch(select(server, null, true));
|
||||
} else {
|
||||
dispatch(select(serverAddrs.sort()[0], null, true));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setSelectedTab(server, name = null) {
|
||||
return {
|
||||
type: actions.SELECT_TAB,
|
||||
server,
|
||||
name
|
||||
};
|
||||
}
|
@ -1,30 +1,29 @@
|
||||
import { Record } from 'immutable';
|
||||
import createReducer from 'util/createReducer';
|
||||
import { LOCATION_CHANGED } from 'util/router';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { LOCATION_CHANGED } from 'utils/router';
|
||||
import * as actions from './actions';
|
||||
|
||||
const State = Record({
|
||||
const initialState = {
|
||||
showTabList: false,
|
||||
showUserList: false
|
||||
});
|
||||
};
|
||||
|
||||
export const getShowTabList = state => state.ui.showTabList;
|
||||
export const getShowUserList = state => state.ui.showUserList;
|
||||
|
||||
function setMenuHidden(state) {
|
||||
return state.set('showTabList', false);
|
||||
state.showTabList = false;
|
||||
}
|
||||
|
||||
export default createReducer(new State(), {
|
||||
export default createReducer(initialState, {
|
||||
[actions.TOGGLE_MENU](state) {
|
||||
return state.update('showTabList', show => !show);
|
||||
state.showTabList = !state.showTabList;
|
||||
},
|
||||
|
||||
[actions.HIDE_MENU]: setMenuHidden,
|
||||
[LOCATION_CHANGED]: setMenuHidden,
|
||||
|
||||
[actions.TOGGLE_USERLIST](state) {
|
||||
return state.update('showUserList', show => !show);
|
||||
state.showUserList = !state.showUserList;
|
||||
}
|
||||
});
|
||||
|
30
client/js/store.js
Normal file
30
client/js/store.js
Normal file
@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
6
client/js/sw.js
Normal file
6
client/js/sw.js
Normal file
@ -0,0 +1,6 @@
|
||||
workbox.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
|
||||
ignoreUrlParametersMatching: [/.*/]
|
||||
});
|
@ -13,6 +13,7 @@ export default class Socket {
|
||||
jitter: 0.25
|
||||
});
|
||||
this.handlers = [];
|
||||
this.connected = false;
|
||||
|
||||
this.connect();
|
||||
}
|
||||
@ -26,6 +27,7 @@ export default class Socket {
|
||||
}, this.connectTimeout);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.connected = true;
|
||||
this.emit('_connected', true);
|
||||
clearTimeout(this.timeoutConnect);
|
||||
this.backoff.reset();
|
||||
@ -33,7 +35,10 @@ export default class Socket {
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.emit('_connected', false);
|
||||
if (this.connected) {
|
||||
this.connected = false;
|
||||
this.emit('_connected', false);
|
||||
}
|
||||
clearTimeout(this.timeoutConnect);
|
||||
clearTimeout(this.timeoutPing);
|
||||
if (!this.closing) {
|
||||
@ -50,13 +55,14 @@ export default class Socket {
|
||||
this.retry();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (e) => {
|
||||
this.ws.onmessage = e => {
|
||||
this.setTimeoutPing();
|
||||
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
this.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit(msg.type, msg.data);
|
120
client/js/utils/__tests__/util.test.js
Normal file
120
client/js/utils/__tests__/util.test.js
Normal file
@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
import { isChannel, isValidNick, isValidChannel, isValidUsername } from '..';
|
||||
import linkify from '../linkify';
|
||||
|
||||
const render = el => TestRenderer.create(el).toJSON();
|
||||
|
||||
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 =>
|
||||
render(
|
||||
<a href={proto(href)} rel="noopener noreferrer" target="_blank">
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
|
||||
it('returns the arg when no matches are found', () =>
|
||||
[null, undefined, 10, false, true, 'just some text', ''].forEach(input =>
|
||||
expect(linkify(input)).toBe(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(render(linkify(input))).toEqual(expected)
|
||||
));
|
||||
});
|
42
client/js/utils/color.js
Normal file
42
client/js/utils/color.js
Normal file
@ -0,0 +1,42 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
import { hsluvToHex } from 'hsluv';
|
||||
|
||||
//
|
||||
// github.com/sindresorhus/fnv1a
|
||||
//
|
||||
const OFFSET_BASIS_32 = 2166136261;
|
||||
|
||||
const fnv1a = string => {
|
||||
let hash = OFFSET_BASIS_32;
|
||||
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
hash ^= string.charCodeAt(i);
|
||||
|
||||
// 32-bit FNV prime: 2**24 + 2**8 + 0x93 = 16777619
|
||||
// Using bitshift for accuracy and performance. Numbers in JS suck.
|
||||
hash +=
|
||||
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
13
client/js/utils/connect.js
Normal file
13
client/js/utils/connect.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const strictEqual = (a, b) => a === b;
|
||||
|
||||
export default (mapState, mapDispatch) =>
|
||||
connect(
|
||||
mapState,
|
||||
mapDispatch,
|
||||
null,
|
||||
{
|
||||
areStatePropsEqual: strictEqual
|
||||
}
|
||||
);
|
11
client/js/utils/createReducer.js
Normal file
11
client/js/utils/createReducer.js
Normal file
@ -0,0 +1,11 @@
|
||||
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;
|
||||
};
|
||||
}
|
189
client/js/utils/index.js
Normal file
189
client/js/utils/index.js
Normal file
@ -0,0 +1,189 @@
|
||||
import padStart from 'lodash/padStart';
|
||||
|
||||
export { findBreakpoints, messageHeight } from './messageHeight';
|
||||
export { default as linkify } from './linkify';
|
||||
|
||||
export function normalizeChannel(channel) {
|
||||
if (channel.indexOf('#') !== 0) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
return channel
|
||||
.split('#')
|
||||
.join('')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isChannel(name) {
|
||||
// TODO: Handle other channel types
|
||||
if (typeof name === 'object') {
|
||||
({ name } = name);
|
||||
}
|
||||
return typeof name === 'string' && name[0] === '#';
|
||||
}
|
||||
|
||||
export function stringifyTab(server, name) {
|
||||
if (typeof server === 'object') {
|
||||
if (server.name) {
|
||||
return `${server.server};${server.name}`;
|
||||
}
|
||||
return server.server;
|
||||
}
|
||||
if (name) {
|
||||
return `${server};${name}`;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
function isString(s, maxLength) {
|
||||
if (!s || typeof s !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (maxLength && s.length > maxLength) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// RFC 2812
|
||||
// nickname = ( letter / special ) *( letter / digit / special / "-" )
|
||||
// letter = A-Z / a-z
|
||||
// digit = 0-9
|
||||
// special = "[", "]", "\", "`", "_", "^", "{", "|", "}"
|
||||
export function isValidNick(nick, maxLength = 30) {
|
||||
if (!isString(nick, maxLength)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < nick.length; i++) {
|
||||
const char = nick.charCodeAt(i);
|
||||
if (
|
||||
(i > 0 && char < 45) ||
|
||||
(char > 45 && char < 48) ||
|
||||
(char > 57 && char < 65) ||
|
||||
char > 125
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
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 (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 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 (!arr) {
|
||||
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;
|
||||
}
|
@ -30,12 +30,20 @@ export default function linkify(text) {
|
||||
}
|
||||
|
||||
result.push(
|
||||
<a target="_blank" rel="noopener noreferrer" href={match.getAnchorHref()}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={match.getAnchorHref()}
|
||||
key={i}
|
||||
>
|
||||
{match.matchedText}
|
||||
</a>
|
||||
);
|
||||
} else if (typeof result[result.length - 1] === 'string') {
|
||||
result[result.length - 1] += text.slice(pos, match.offset + match.matchedText.length);
|
||||
result[result.length - 1] += text.slice(
|
||||
pos,
|
||||
match.offset + match.matchedText.length
|
||||
);
|
||||
} else {
|
||||
result.push(text.slice(pos, match.offset + match.matchedText.length));
|
||||
}
|
@ -18,7 +18,13 @@ export function findBreakpoints(text) {
|
||||
return breakpoints;
|
||||
}
|
||||
|
||||
export function messageHeight(message, wrapWidth, charWidth, indent = 0, windowWidth) {
|
||||
export function messageHeight(
|
||||
message,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
indent = 0,
|
||||
windowWidth
|
||||
) {
|
||||
let pad = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
|
||||
let height = lineHeight + 8;
|
||||
|
||||
@ -26,7 +32,7 @@ export function messageHeight(message, wrapWidth, charWidth, indent = 0, windowW
|
||||
wrapWidth -= userListWidth;
|
||||
}
|
||||
|
||||
if (pad + (message.length * charWidth) < wrapWidth) {
|
||||
if (pad + message.length * charWidth < wrapWidth) {
|
||||
return height;
|
||||
}
|
||||
|
||||
@ -35,7 +41,7 @@ export function messageHeight(message, wrapWidth, charWidth, indent = 0, windowW
|
||||
let prevPos = 0;
|
||||
|
||||
for (let i = 0; i < breaks.length; i++) {
|
||||
if (pad + ((breaks[i].end - prevBreak) * charWidth) >= wrapWidth) {
|
||||
if (pad + (breaks[i].end - prevBreak) * charWidth >= wrapWidth) {
|
||||
prevBreak = prevPos;
|
||||
pad = indent;
|
||||
height += lineHeight;
|
||||
@ -44,7 +50,7 @@ export function messageHeight(message, wrapWidth, charWidth, indent = 0, windowW
|
||||
prevPos = breaks[i].next;
|
||||
}
|
||||
|
||||
if (pad + ((message.length - prevBreak) * charWidth) >= wrapWidth) {
|
||||
if (pad + (message.length - prevBreak) * charWidth >= wrapWidth) {
|
||||
height += lineHeight;
|
||||
}
|
||||
|
@ -99,7 +99,10 @@ export default function initRouter(routes, store) {
|
||||
|
||||
history.listen(location => {
|
||||
const nextMatch = match(patterns, location);
|
||||
if (nextMatch && nextMatch.location.pathname !== matched.location.pathname) {
|
||||
if (
|
||||
nextMatch &&
|
||||
nextMatch.location.pathname !== matched.location.pathname
|
||||
) {
|
||||
matched = nextMatch;
|
||||
store.dispatch(matched);
|
||||
}
|
45
client/js/utils/size.js
Normal file
45
client/js/utils/size.js
Normal file
@ -0,0 +1,45 @@
|
||||
let width, 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);
|
||||
}
|
||||
}
|
@ -4,64 +4,97 @@
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"browserslist": [
|
||||
"Edge >= 16",
|
||||
"Firefox >= 60",
|
||||
"Chrome >= 61",
|
||||
"Safari >= 10.1",
|
||||
"iOS >= 10.3"
|
||||
],
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.23.1",
|
||||
"babel-eslint": "^7.1.1",
|
||||
"babel-jest": "^20.0.0",
|
||||
"babel-loader": "^7.0.0",
|
||||
"babel-plugin-module-resolver": "^2.7.1",
|
||||
"babel-plugin-rewire": "^1.1.0",
|
||||
"babel-plugin-transform-react-constant-elements": "^6.23.0",
|
||||
"babel-plugin-transform-react-inline-elements": "^6.22.0",
|
||||
"babel-preset-es2015": "^6.22.0",
|
||||
"babel-preset-react": "^6.23.0",
|
||||
"babel-preset-stage-0": "^6.22.0",
|
||||
"@babel/core": "^7.1.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.1.0",
|
||||
"@babel/plugin-proposal-export-default-from": "^7.0.0",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.0.0",
|
||||
"@babel/preset-env": "^7.1.5",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"babel-core": "^7.0.0-0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-jest": "^23.6.0",
|
||||
"babel-loader": "^8.0.4",
|
||||
"brotli": "^1.3.1",
|
||||
"css-loader": "^0.28.0",
|
||||
"eslint": "^3.15.0",
|
||||
"eslint-config-airbnb": "^14.1.0",
|
||||
"eslint-import-resolver-babel-module": "^3.0.0",
|
||||
"eslint-loader": "^1.6.1",
|
||||
"eslint-plugin-import": "^2.2.0",
|
||||
"eslint-plugin-jsx-a11y": "^4.0.0",
|
||||
"eslint-plugin-react": "^6.10.0",
|
||||
"express": "^4.14.1",
|
||||
"express-http-proxy": "^1.0.1",
|
||||
"gulp": "^3.9.1",
|
||||
"gulp-autoprefixer": "^4.0.0",
|
||||
"gulp-cached": "^1.1.1",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-cssnano": "^2.1.2",
|
||||
"css-loader": "^1.0.1",
|
||||
"cssnano": "^4.1.7",
|
||||
"del": "^3.0.0",
|
||||
"eslint": "^5.8.0",
|
||||
"eslint-config-airbnb": "^17.1.0",
|
||||
"eslint-config-prettier": "^3.1.0",
|
||||
"eslint-import-resolver-webpack": "^0.10.1",
|
||||
"eslint-loader": "^2.1.1",
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.1.2",
|
||||
"eslint-plugin-react": "^7.11.1",
|
||||
"express": "^4.16.4",
|
||||
"express-http-proxy": "^1.5.0",
|
||||
"gulp": "4.0.0",
|
||||
"gulp-util": "^3.0.8",
|
||||
"jest": "^20.0.0",
|
||||
"style-loader": "^0.18.0",
|
||||
"through2": "^2.0.3",
|
||||
"webpack": "^3.0.0",
|
||||
"webpack-dev-middleware": "^1.10.0",
|
||||
"webpack-hot-middleware": "^2.17.0"
|
||||
"jest": "^23.6.0",
|
||||
"mini-css-extract-plugin": "^0.4.4",
|
||||
"postcss-flexbugs-fixes": "^4.1.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.4.0",
|
||||
"prettier": "1.15.1",
|
||||
"react-test-renderer": "^16.7.0-alpha.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"terser-webpack-plugin": "^1.1.0",
|
||||
"through2": "^3.0.0",
|
||||
"webpack": "^4.25.1",
|
||||
"webpack-dev-middleware": "^3.4.0",
|
||||
"webpack-hot-middleware": "^2.24.3",
|
||||
"workbox-webpack-plugin": "^3.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"autolinker": "^1.4.3",
|
||||
"autolinker": "^1.7.1",
|
||||
"backo": "^1.1.0",
|
||||
"base64-arraybuffer": "^0.1.5",
|
||||
"classnames": "^2.2.6",
|
||||
"fontfaceobserver": "^2.0.9",
|
||||
"formik": "^1.3.1",
|
||||
"history": "4.5.1",
|
||||
"immutable": "^3.8.1",
|
||||
"hsluv": "^0.0.3",
|
||||
"immer": "^1.7.3",
|
||||
"js-cookie": "^2.1.4",
|
||||
"lodash": "^4.17.4",
|
||||
"react": "^15.4.2",
|
||||
"react-dom": "^15.4.2",
|
||||
"react-hot-loader": "next",
|
||||
"react-redux": "^5.0.2",
|
||||
"react-virtualized": "^9.3.0",
|
||||
"redux": "^3.6.0",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"reselect": "^3.0.0",
|
||||
"lodash": "^4.17.11",
|
||||
"react": "^16.7.0-alpha.0",
|
||||
"react-dom": "^16.7.0-alpha.0",
|
||||
"react-hot-loader": "^4.3.11",
|
||||
"react-redux": "^6.0.0-beta.2",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-window": "^1.2.2",
|
||||
"redux": "^4.0.1",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"url-pattern": "^1.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"prettier": "prettier --write {.*,*.js,src/css/*.css,src/**/*.test.js}",
|
||||
"prettier:all": "prettier --write {.*,*.js,src/**/*.js,src/css/*.css}",
|
||||
"test": "jest",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:watch": "jest --watch"
|
||||
"test:watch": "jest --watch",
|
||||
"gen:install": "GO111MODULE=off go get -u github.com/andyleap/gencode github.com/mailru/easyjson/... github.com/SlinSo/egon/cmd/egon",
|
||||
"gen:binary": "gencode go -package storage -schema ../storage/storage.schema -unsafe",
|
||||
"gen:json": "easyjson -all -lower_camel_case -omit_empty ../server/json.go ../server/index_data.go && easyjson -lower_camel_case -omit_empty ../storage/user.go",
|
||||
"gen:template": "egon -s -m ../server"
|
||||
},
|
||||
"jest": {
|
||||
"moduleNameMapper": {
|
||||
"^components(.*)$": "<rootDir>/js/components$1",
|
||||
"^containers(.*)$": "<rootDir>/js/containers$1",
|
||||
"^state(.*)$": "<rootDir>/js/state$1",
|
||||
"^utils(.*)$": "<rootDir>/js/utils$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user