Compare commits
174 Commits
v0.5.1
...
i2p-suppor
Author | SHA1 | Date | |
---|---|---|---|
8f18eaa4cf | |||
f997149dd6 | |||
a02ad3a299 | |||
ea4f321fe7 | |||
1fe4c4d17e | |||
307573830a | |||
ca4db66308 | |||
d844f6ee1a | |||
02e9df865e | |||
45f8795fad | |||
e0ca9d5d8c | |||
972f568a00 | |||
b9f52a8761 | |||
ca23b3ded8 | |||
1c996822cd | |||
f89e6ae133 | |||
4694e66e98 | |||
fc937aaac8 | |||
fd5e50a2cb | |||
04e6e8c7a2 | |||
15ee5ce1c9 | |||
e0d2243248 | |||
67fe5d263d | |||
7040f1c8d0 | |||
2ea4584c97 | |||
fcf0c17682 | |||
6985dd16da | |||
a33157ff84 | |||
a4fe18c0f0 | |||
9d8d04fa7c | |||
876d9ebdd0 | |||
ead3b37cf9 | |||
edd4d6eadb | |||
e76beca4a0 | |||
8829793290 | |||
71b2136a9e | |||
e97c7f2ada | |||
9aac4f4e29 | |||
2f8dad2529 | |||
be8b785813 | |||
e937f5d8b9 | |||
99b3ff519b | |||
35727fb2b8 | |||
1abe280957 | |||
e33b9f05e4 | |||
973578bb49 | |||
4816fbfbca | |||
f0eada0f75 | |||
abbe739b04 | |||
63afd839be | |||
84a10efe36 | |||
902e4da46f | |||
e05118a29b | |||
a90e8d4b2f | |||
2d68f04ab2 | |||
b92f5cfb43 | |||
fa99a96733 | |||
6b5bf4ced1 | |||
0c902f8ac8 | |||
ed432881ef | |||
75c9560dfb | |||
1532b2a8c8 | |||
4b8491cf99 | |||
2509420ba5 | |||
ed2e56948e | |||
9581a63e81 | |||
8fa91ac470 | |||
eb7545455c | |||
eab788a782 | |||
dcbf3397c1 | |||
1773fef8ef | |||
08ffc79a65 | |||
2a72b198e3 | |||
0d085a2b4d | |||
497f9e882c | |||
7d97d10e76 | |||
8305dd561d | |||
e97bb519ed | |||
18acde5b2b | |||
bab4732221 | |||
79af695d17 | |||
d98312f99b | |||
010bb6a102 | |||
530e08b9ee | |||
9cf42df1ea | |||
b81e1e482a | |||
3d7011e504 | |||
3d2e443108 | |||
b002eef285 | |||
c566d5d61d | |||
13a31c30d9 | |||
508a41ee45 | |||
1794e2680a | |||
c704ebb042 | |||
bb66740fd1 | |||
4010132884 | |||
77543e3aed | |||
360bed00f9 | |||
164e071e7f | |||
01914f070d | |||
00e40dc153 | |||
47efab2e56 | |||
c171a620e0 | |||
ca81475fa5 | |||
52b2b6677f | |||
5013ab6db1 | |||
855f4d3e64 | |||
540efa03c4 | |||
7ad76273c0 | |||
c1e1f2c327 | |||
4eda7ef396 | |||
fad2e030d4 | |||
3e90e6c86d | |||
71bfe92dae | |||
613d9fca6e | |||
9267c661dc | |||
f8e12f5938 | |||
497934888c | |||
24960f23b9 | |||
aab1ad3e99 | |||
815b518c2c | |||
5e674254f0 | |||
24b26aa85f | |||
f25594e962 | |||
075e404079 | |||
eee260f154 | |||
a3618b97ae | |||
e4d5d2737b | |||
0085cea5a1 | |||
63cf65100d | |||
67e32661f1 | |||
95eff71e2e | |||
8526805c2f | |||
6aaa2b521d | |||
0d9290d037 | |||
6fedb23363 | |||
fc643483be | |||
6c3a5777c4 | |||
c5a9a5b1c1 | |||
6a816fbff6 | |||
3c105c493b | |||
50d735aaa3 | |||
34d89c75b2 | |||
71f79fd84e | |||
8f1105bc59 | |||
0e46fbcc82 | |||
c1ca29511e | |||
35c2d682e3 | |||
aca380629f | |||
007fc80e72 | |||
cca3f5bc93 | |||
f05e405fb1 | |||
73205b64f6 | |||
5861a54dfc | |||
869713d236 | |||
0438a099cf | |||
df71c54d37 | |||
d24d33d94c | |||
57704c9e89 | |||
dd03e75be4 | |||
4eac0609c2 | |||
bf2e7875a5 | |||
fbbcf6457e | |||
5d896ae439 | |||
9b40934654 | |||
bd0541120f | |||
09a1933131 | |||
8eb1f9cbb4 | |||
9ca50b1546 | |||
0699fc287d | |||
937480bfd4 | |||
05527e41b7 | |||
65e103fd23 | |||
2a7b7572fa |
@ -1,3 +1,5 @@
|
||||
.git
|
||||
dist
|
||||
dispatch
|
||||
client/dist
|
||||
client/node_modules
|
||||
client/node_modules
|
||||
client/yarn-error.log
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,4 +3,3 @@ dispatch
|
||||
client/dist
|
||||
client/node_modules
|
||||
client/yarn-error.log
|
||||
ca-certificates.crt
|
||||
|
@ -1,6 +1,6 @@
|
||||
builds:
|
||||
- ldflags:
|
||||
- -s -w -X github.com/khlieng/dispatch/commands.version=v{{.Version}} -X github.com/khlieng/dispatch/commands.commit={{.ShortCommit}} -X github.com/khlieng/dispatch/commands.date={{.Date}}
|
||||
- -s -w -X github.com/khlieng/dispatch/version.Tag=v{{.Version}} -X github.com/khlieng/dispatch/version.Commit={{.ShortCommit}} -X github.com/khlieng/dispatch/version.Date={{.Date}}
|
||||
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
@ -19,13 +19,17 @@ builds:
|
||||
- 6
|
||||
- 7
|
||||
|
||||
archive:
|
||||
files:
|
||||
- none*
|
||||
archives:
|
||||
- files:
|
||||
- none*
|
||||
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
replacements:
|
||||
amd64: x64
|
||||
darwin: mac
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
@ -13,12 +13,12 @@ matrix:
|
||||
- go: tip
|
||||
|
||||
install:
|
||||
- go get github.com/jteeuwen/go-bindata/...
|
||||
- GO111MODULE=off go get github.com/jteeuwen/go-bindata/...
|
||||
- cd client
|
||||
- nvm install 11.2.0
|
||||
- nvm use 11.2.0
|
||||
- nvm install node
|
||||
- nvm use node
|
||||
- npm install -g yarn
|
||||
- yarn global add gulp@next
|
||||
- yarn global add gulp-cli
|
||||
- yarn
|
||||
|
||||
script:
|
||||
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
@ -6,7 +6,7 @@ RUN apk add --update git make build-base && \
|
||||
|
||||
WORKDIR /go/src/github.com/khlieng/dispatch
|
||||
COPY . /go/src/github.com/khlieng/dispatch
|
||||
RUN go build .
|
||||
RUN chmod +x install.sh && ./install.sh
|
||||
|
||||
# Runtime
|
||||
FROM alpine
|
||||
@ -14,7 +14,7 @@ FROM alpine
|
||||
RUN apk add --update ca-certificates && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY --from=build /go/src/github.com/khlieng/dispatch/dispatch /dispatch
|
||||
COPY --from=build /go/bin/dispatch /dispatch
|
||||
|
||||
EXPOSE 80/tcp
|
||||
|
||||
|
22
README.md
22
README.md
@ -10,6 +10,9 @@
|
||||
- Persistent connections
|
||||
- Multiple servers and users
|
||||
- Automatic HTTPS through Let's Encrypt
|
||||
- Single binary with no dependencies
|
||||
- DCC downloads
|
||||
- SASL
|
||||
- Client certificates
|
||||
|
||||
## Usage
|
||||
@ -18,14 +21,14 @@ There is a few different ways of getting it:
|
||||
|
||||
### 1. Binary
|
||||
|
||||
- **[Windows (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5.1/dispatch_windows_amd64.zip)**
|
||||
- **[macOS (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5.1/dispatch_darwin_amd64.zip)**
|
||||
- **[Linux (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5.1/dispatch_linux_amd64.tar.gz)**
|
||||
- **[Windows (x64)](https://release.khlieng.com/khlieng/dispatch/windows_x64)**
|
||||
- **[macOS (x64)](https://release.khlieng.com/khlieng/dispatch/mac_x64)**
|
||||
- **[Linux (x64)](https://release.khlieng.com/khlieng/dispatch/linux_x64)**
|
||||
- [Other versions](https://github.com/khlieng/dispatch/releases)
|
||||
|
||||
### 2. Go
|
||||
|
||||
This requires a [Go environment](http://golang.org/doc/install), version 1.10 or greater.
|
||||
This requires a [Go environment](http://golang.org/doc/install), version 1.11 or greater.
|
||||
|
||||
Fetch, compile and run dispatch:
|
||||
|
||||
@ -51,7 +54,6 @@ docker run -p <http port>:80 -p <https port>:443 -v <path>:/data khlieng/dispatc
|
||||
### Server
|
||||
|
||||
```bash
|
||||
cd $GOPATH/src/github.com/khlieng/dispatch
|
||||
go install
|
||||
```
|
||||
|
||||
@ -62,9 +64,9 @@ This requires [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com).
|
||||
Fetch the dependencies:
|
||||
|
||||
```bash
|
||||
go get github.com/jteeuwen/go-bindata/...
|
||||
yarn global add gulp@next
|
||||
cd $GOPATH/src/github.com/khlieng/dispatch/client
|
||||
GO111MODULE=off go get github.com/jteeuwen/go-bindata/...
|
||||
yarn global add gulp-cli
|
||||
cd client
|
||||
yarn
|
||||
```
|
||||
|
||||
@ -94,11 +96,11 @@ The libraries this project is built with.
|
||||
|
||||
### Server
|
||||
|
||||
- [Bolt](https://github.com/boltdb/bolt)
|
||||
- [Bolt](https://github.com/etcd-io/bbolt)
|
||||
- [Bleve](https://github.com/blevesearch/bleve)
|
||||
- [Cobra](https://github.com/spf13/cobra)
|
||||
- [Viper](https://github.com/spf13/viper)
|
||||
- [Lego](https://github.com/xenolf/lego)
|
||||
- [CertMagic](https://github.com/mholt/certmagic)
|
||||
|
||||
### Client
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -4,6 +4,7 @@
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"plugins": ["babel"],
|
||||
"rules": {
|
||||
"consistent-return": 0,
|
||||
"jsx-a11y/click-events-have-key-events": 0,
|
||||
@ -13,9 +14,16 @@
|
||||
"no-param-reassign": 0,
|
||||
"no-plusplus": 0,
|
||||
"no-restricted-globals": 1,
|
||||
"no-underscore-dangle": 1,
|
||||
"react/destructuring-assignment": 0,
|
||||
"react/jsx-filename-extension": 0,
|
||||
"react/prop-types": 0
|
||||
"react/jsx-props-no-spreading": 0,
|
||||
"react/prop-types": 0,
|
||||
"react/state-in-constructor": 0,
|
||||
"react/static-property-placement": 0,
|
||||
|
||||
"no-unused-expressions": 0,
|
||||
"babel/no-unused-expressions": 2
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
|
@ -1,3 +1,5 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
|
@ -13,12 +13,10 @@ module.exports = {
|
||||
['@babel/plugin-proposal-class-properties', { loose: true }],
|
||||
'@babel/plugin-proposal-export-default-from',
|
||||
'@babel/plugin-proposal-export-namespace-from',
|
||||
'@babel/plugin-syntax-dynamic-import'
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'react-hot-loader/babel'
|
||||
],
|
||||
env: {
|
||||
development: {
|
||||
plugins: ['react-hot-loader/babel']
|
||||
},
|
||||
test: {
|
||||
plugins: ['@babel/plugin-transform-modules-commonjs']
|
||||
},
|
62
client/css/fontello.css
vendored
62
client/css/fontello.css
vendored
@ -1,62 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'fontello';
|
||||
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';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: none;
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: 0.2em;
|
||||
text-align: center;
|
||||
/* opacity: .8; */
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
/* Animation center compensation - margins should be symmetric */
|
||||
/* remove if not needed */
|
||||
margin-left: 0.2em;
|
||||
|
||||
/* you can be more comfortable with increased icons size */
|
||||
/* font-size: 120%; */
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Uncomment for 3D effect */
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
||||
.icon-cancel:before {
|
||||
content: '\e800';
|
||||
} /* '' */
|
||||
.icon-menu:before {
|
||||
content: '\e801';
|
||||
} /* '' */
|
||||
.icon-cog:before {
|
||||
content: '\e802';
|
||||
} /* '' */
|
||||
.icon-search:before {
|
||||
content: '\e803';
|
||||
} /* '' */
|
||||
.icon-user:before {
|
||||
content: '\f061';
|
||||
} /* '' */
|
||||
.icon-ellipsis:before {
|
||||
content: '\f141';
|
||||
} /* '' */
|
@ -2,12 +2,34 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Roboto Mono, monospace;
|
||||
background: #f0f0f0;
|
||||
color: #222;
|
||||
scrollbar-color: #ccc rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #999;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
h1,
|
||||
@ -19,6 +41,15 @@ h6 {
|
||||
font-family: Montserrat, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #0066ff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input {
|
||||
font: 16px Roboto Mono, monospace;
|
||||
border: none;
|
||||
@ -31,7 +62,16 @@ input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
border-radius: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: #6bb758;
|
||||
@ -51,6 +91,33 @@ button:active {
|
||||
background: #6bb758;
|
||||
}
|
||||
|
||||
.button-normal {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.button-normal:hover {
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.button-normal:active {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: none;
|
||||
width: 40px;
|
||||
color: #222;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
background: none;
|
||||
}
|
||||
|
||||
label {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
@ -122,6 +189,10 @@ i[class*=' icon-']:before {
|
||||
color: #f6546a !important;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: #999 !important;
|
||||
}
|
||||
|
||||
.textinput {
|
||||
display: block;
|
||||
position: relative;
|
||||
@ -131,7 +202,7 @@ i[class*=' icon-']:before {
|
||||
padding: 25px 15px 10px;
|
||||
}
|
||||
|
||||
.textinput span {
|
||||
.textinput-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@ -212,6 +283,19 @@ i[class*=' icon-']:before {
|
||||
font-family: Montserrat, sans-serif;
|
||||
transition: transform 0.2s;
|
||||
user-select: none;
|
||||
scrollbar-color: #333 rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.tablist ::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.tablist ::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.tablist ::-webkit-scrollbar-thumb:active {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
@ -219,7 +303,8 @@ i[class*=' icon-']:before {
|
||||
top: 0;
|
||||
bottom: 50px;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tablist p {
|
||||
@ -227,6 +312,9 @@ i[class*=' icon-']:before {
|
||||
padding: 3px 15px;
|
||||
padding-right: 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tablist p:last-child {
|
||||
@ -242,30 +330,54 @@ i[class*=' icon-']:before {
|
||||
border-left: 5px solid #6bb758;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-server {
|
||||
.tab-network {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.tab-server .tab-content {
|
||||
.tab-network .tab-content {
|
||||
flex: 1;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.tab-prefix {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.error .tab-prefix {
|
||||
color: #f6546a;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
margin-top: 10px;
|
||||
margin: 5px;
|
||||
margin-left: 15px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.tab-label-channels {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-label span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tab-label button {
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
font-size: 20px;
|
||||
background: none;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tab-label:hover button {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.side-buttons {
|
||||
@ -278,20 +390,13 @@ i[class*=' icon-']:before {
|
||||
border-top: 1px solid #1d1d1d;
|
||||
}
|
||||
|
||||
.side-buttons i {
|
||||
flex: 100%;
|
||||
color: #999;
|
||||
line-height: 50px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
border-left: 1px solid #1d1d1d;
|
||||
}
|
||||
|
||||
.side-buttons button {
|
||||
font-size: 24px;
|
||||
background: #222;
|
||||
color: #999;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.side-buttons i:hover {
|
||||
.side-buttons button:hover {
|
||||
color: #ccc;
|
||||
background: #1d1d1d;
|
||||
}
|
||||
@ -325,7 +430,7 @@ i[class*=' icon-']:before {
|
||||
|
||||
.connect-form {
|
||||
margin: auto 20px;
|
||||
padding-top: 20px;
|
||||
padding: 20px 0;
|
||||
width: 350px;
|
||||
text-align: center;
|
||||
}
|
||||
@ -350,6 +455,17 @@ i[class*=' icon-']:before {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connect-section {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 15px;
|
||||
margin: 15px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.connect-section h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
@ -362,11 +478,6 @@ input::-webkit-inner-spin-button {
|
||||
|
||||
.connect-form label {
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.connect-form button {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.connect-form-address {
|
||||
@ -388,23 +499,25 @@ input::-webkit-inner-spin-button {
|
||||
.connect-form-address label {
|
||||
margin-top: 5px;
|
||||
font: 12px 'Montserrat', sans-serif;
|
||||
padding: 10px;
|
||||
padding: 10px 0;
|
||||
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-ssl {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.connect-form i:hover {
|
||||
.connect-form-button-optionals {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connect-form-button-optionals:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@ -419,6 +532,7 @@ input::-webkit-inner-spin-button {
|
||||
border-bottom: 1px solid #ddd;
|
||||
display: flex;
|
||||
font-size: 20px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.chat-channel .chat-title-bar {
|
||||
@ -427,63 +541,55 @@ input::-webkit-inner-spin-button {
|
||||
|
||||
.navicon {
|
||||
display: none;
|
||||
padding: 0 15px;
|
||||
line-height: 50px;
|
||||
font-size: 20px;
|
||||
width: 50px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-title-bar i {
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-server .icon-search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-server .userlist,
|
||||
.chat-network .userlist,
|
||||
.chat-private .userlist {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-server .userlist-bar,
|
||||
.chat-network .userlist-bar,
|
||||
.chat-private .userlist-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button-leave {
|
||||
border-left: 1px solid #ddd;
|
||||
.chat-title-bar .icon-button:not(.navicon) {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.button-leave:hover {
|
||||
background: #ddd;
|
||||
.chat-title-bar .icon-button:hover {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.button-userlist {
|
||||
display: none;
|
||||
border-left: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.chat-server .button-userlist,
|
||||
.chat-network .button-userlist,
|
||||
.chat-private .button-userlist {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editable-wrap {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.editable-wrap-editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
margin-left: 10px;
|
||||
padding: 0 5px;
|
||||
margin-left: 15px;
|
||||
font: 24px Montserrat, sans-serif;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.chat-server .chat-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input.chat-title {
|
||||
background: none;
|
||||
cursor: text !important;
|
||||
@ -492,7 +598,8 @@ input.chat-title {
|
||||
.chat-topic-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
margin: 0 15px;
|
||||
margin-left: 15px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.chat-topic {
|
||||
@ -501,21 +608,16 @@ input.chat-title {
|
||||
top: 3px;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-topic a {
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-topic a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.userlist-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
@ -523,14 +625,11 @@ input.chat-title {
|
||||
height: 50px;
|
||||
border-left: 1px solid #ddd;
|
||||
border-bottom: 1px solid #ddd;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
padding: 0 15px;
|
||||
font-family: Montserrat, sans-serif;
|
||||
}
|
||||
|
||||
.userlist-bar i {
|
||||
margin-right: 3px;
|
||||
.userlist-bar svg {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.search {
|
||||
@ -544,7 +643,7 @@ input.chat-title {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.chat-server .search {
|
||||
.chat-network .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -559,17 +658,19 @@ input.chat-title {
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.search i {
|
||||
padding: 15px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
font-size: 20px;
|
||||
align-self: center;
|
||||
margin: 0 15px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
@ -589,8 +690,6 @@ input.chat-title {
|
||||
top: 50px;
|
||||
bottom: 50px;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-channel .messagebox {
|
||||
@ -609,6 +708,24 @@ input.chat-title {
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
.messagebox-topdate-container {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
left: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.messagebox-topdate {
|
||||
position: relative;
|
||||
top: -12px;
|
||||
background: #f0f0f0;
|
||||
color: #999;
|
||||
border-radius: 50vh;
|
||||
padding: 0 5px;
|
||||
font-size: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 4px 15px;
|
||||
}
|
||||
@ -631,6 +748,18 @@ input.chat-title {
|
||||
color: #ff6698;
|
||||
}
|
||||
|
||||
.message-date {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.message-date hr {
|
||||
border: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
@ -643,13 +772,9 @@ input.chat-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message a {
|
||||
text-decoration: none;
|
||||
color: #0066ff;
|
||||
}
|
||||
|
||||
.message a:hover {
|
||||
text-decoration: underline;
|
||||
.message-events-more {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-input-wrap {
|
||||
@ -689,7 +814,7 @@ input.message-input-nick.invalid {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 15px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.userlist {
|
||||
@ -700,7 +825,7 @@ input.message-input-nick.invalid {
|
||||
width: 200px;
|
||||
border-left: 1px solid #ddd;
|
||||
background: #f0f0f0;
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
@ -744,11 +869,10 @@ input.message-input-nick.invalid {
|
||||
}
|
||||
|
||||
.settings h2 {
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.settings button {
|
||||
.settings-button {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@ -792,8 +916,8 @@ input.message-input-nick.invalid {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
margin-top: 10px;
|
||||
.settings-file:last-of-type {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -806,15 +930,187 @@ input.message-input-nick.invalid {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-version {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.suspense-fallback {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 700 64px 'Montserrat', sans-serif;
|
||||
height: 100%;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.suspense-modal-fallback {
|
||||
position: fixed;
|
||||
right: 15px;
|
||||
bottom: 3px;
|
||||
z-index: 1;
|
||||
font: 700 64px 'Montserrat', sans-serif;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.33);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.modal-overlay-opening {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-overlay-closing {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 600px;
|
||||
min-width: 0;
|
||||
padding: 15px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
outline: none;
|
||||
margin: 15px;
|
||||
text-align: center;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
transform: translateY(-20px);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.modal-opening {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-closing {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.modal button {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.modal button {
|
||||
margin: 0 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
width: auto !important;
|
||||
height: auto;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.modal-channel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 120px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-channel input {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.modal-channel-button-join {
|
||||
margin: 0 !important;
|
||||
width: 60px !important;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.modal-channel-input-wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-channel-close {
|
||||
background: #fff;
|
||||
width: 40px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.modal-channel-close:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.modal-channel-result {
|
||||
margin: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.modal-channel-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.modal-channel-topic {
|
||||
font-size: 12px;
|
||||
font-family: Roboto Mono, monospace;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.modal-channel-name {
|
||||
cursor: pointer;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.modal-channel-users {
|
||||
font-size: 16px;
|
||||
flex: 1;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.modal-channel-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.modal-channel-button-more {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.tablist {
|
||||
width: 200px;
|
||||
@ -827,7 +1123,7 @@ input.message-input-nick.invalid {
|
||||
}
|
||||
|
||||
.navicon {
|
||||
display: inline-block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main-container.off-canvas {
|
||||
@ -842,8 +1138,12 @@ input.message-input-nick.invalid {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.chat-topic {
|
||||
font-size: 12px;
|
||||
.chat-title-bar .editable-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-topic-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.userlist-bar {
|
||||
@ -884,4 +1184,15 @@ input.message-input-nick.invalid {
|
||||
.button-install {
|
||||
margin-left: 50px;
|
||||
}
|
||||
|
||||
.modal-channel {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,4 @@
|
||||
var path = require('path');
|
||||
var exec = require('child_process').exec;
|
||||
var url = require('url');
|
||||
|
||||
var gulp = require('gulp');
|
||||
var gutil = require('gulp-util');
|
||||
@ -12,7 +10,7 @@ var br = require('brotli');
|
||||
var del = require('del');
|
||||
|
||||
function brotli(opts) {
|
||||
return through.obj(function(file, enc, callback) {
|
||||
return through.obj(function (file, enc, callback) {
|
||||
if (file.isNull()) {
|
||||
return callback(null, file);
|
||||
}
|
||||
@ -40,8 +38,10 @@ function js(cb) {
|
||||
|
||||
process.env['NODE_ENV'] = 'production';
|
||||
|
||||
compiler.run(function(err, stats) {
|
||||
if (err) throw new gutil.PluginError('webpack', err);
|
||||
compiler.run(function (err, stats) {
|
||||
if (err) {
|
||||
throw new gutil.PluginError('webpack', err);
|
||||
}
|
||||
|
||||
gutil.log(
|
||||
'[webpack]',
|
||||
@ -50,7 +50,9 @@ function js(cb) {
|
||||
})
|
||||
);
|
||||
|
||||
if (stats.hasErrors()) process.exit(1);
|
||||
if (stats.hasErrors()) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
cb();
|
||||
});
|
||||
@ -104,13 +106,13 @@ function serve() {
|
||||
app.use(
|
||||
'*',
|
||||
proxy('localhost:1337', {
|
||||
proxyReqPathResolver: function(req) {
|
||||
proxyReqPathResolver: function (req) {
|
||||
return req.originalUrl;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
app.listen(3000, function(err) {
|
||||
app.listen(3000, function (err) {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
return;
|
||||
@ -120,9 +122,13 @@ function serve() {
|
||||
});
|
||||
}
|
||||
|
||||
const assets = gulp.parallel(js, config, public);
|
||||
|
||||
const build = gulp.series(clean, assets, compress, cleanup, bindata);
|
||||
const build = gulp.series(
|
||||
clean,
|
||||
gulp.parallel(js, config),
|
||||
compress,
|
||||
cleanup,
|
||||
bindata
|
||||
);
|
||||
|
||||
const dev = gulp.series(
|
||||
clean,
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { COMMAND } from 'state/actions';
|
||||
import { join, part, invite, kick, setTopic } from 'state/channels';
|
||||
import { sendMessage, raw } from 'state/messages';
|
||||
import { setNick, disconnect, whois, away } from 'state/servers';
|
||||
import { setNick, disconnect, whois, away } from 'state/networks';
|
||||
import { openPrivateChat } from 'state/privateChats';
|
||||
import { select } from 'state/tab';
|
||||
import { find } from 'utils';
|
||||
import { find, isChannel } from 'utils';
|
||||
import createCommandMiddleware, {
|
||||
beforeHandler,
|
||||
notFoundHandler
|
||||
@ -13,7 +14,7 @@ const help = [
|
||||
'/join <channel> - Join a channel',
|
||||
'/part [channel] - Leave the current or specified channel',
|
||||
'/nick <nick> - Change nick',
|
||||
'/quit - Disconnect from the current server',
|
||||
'/quit - Disconnect from the current network',
|
||||
'/me <message> - Send action message',
|
||||
'/topic [topic] - Show or set topic in the current channel',
|
||||
'/msg <target> <message> - Send message to the specified channel or user',
|
||||
@ -22,7 +23,7 @@ const help = [
|
||||
'/kick <nick> - Kick user from the current channel',
|
||||
'/whois <nick> - Get information about user',
|
||||
'/away [message] - Set or clear away message',
|
||||
'/raw [message] - Send raw IRC message to the current server',
|
||||
'/raw [message] - Send raw IRC message to the current network',
|
||||
'/help [command]... - Print help for all or the specified command(s)'
|
||||
];
|
||||
|
||||
@ -33,54 +34,55 @@ const findHelp = cmd =>
|
||||
find(help, line => line.slice(1, line.indexOf(' ')) === cmd);
|
||||
|
||||
export default createCommandMiddleware(COMMAND, {
|
||||
join({ dispatch, server }, channel) {
|
||||
join({ dispatch, network }, channel) {
|
||||
if (channel) {
|
||||
if (channel[0] !== '#') {
|
||||
return error('Bad channel name');
|
||||
}
|
||||
dispatch(join([channel], server));
|
||||
dispatch(select(server, channel));
|
||||
dispatch(join([channel], network));
|
||||
} else {
|
||||
return error('Missing channel');
|
||||
}
|
||||
},
|
||||
|
||||
part({ dispatch, server, channel, isChannel }, partChannel) {
|
||||
part({ dispatch, network, channel, inChannel }, partChannel) {
|
||||
if (partChannel) {
|
||||
dispatch(part([partChannel], server));
|
||||
} else if (isChannel) {
|
||||
dispatch(part([channel], server));
|
||||
dispatch(part([partChannel], network));
|
||||
} else if (inChannel) {
|
||||
dispatch(part([channel], network));
|
||||
} else {
|
||||
return error('This is not a channel');
|
||||
}
|
||||
},
|
||||
|
||||
nick({ dispatch, server }, nick) {
|
||||
nick({ dispatch, network }, nick) {
|
||||
if (nick) {
|
||||
dispatch(setNick(nick, server));
|
||||
dispatch(setNick(nick, network));
|
||||
} else {
|
||||
return error('Missing nick');
|
||||
}
|
||||
},
|
||||
|
||||
quit({ dispatch, server }) {
|
||||
dispatch(disconnect(server));
|
||||
quit({ dispatch, network }) {
|
||||
dispatch(disconnect(network));
|
||||
},
|
||||
|
||||
me({ dispatch, server, channel }, ...message) {
|
||||
me({ dispatch, network, channel }, ...message) {
|
||||
const msg = message.join(' ');
|
||||
if (msg !== '') {
|
||||
dispatch(sendMessage(`\x01ACTION ${msg}\x01`, channel, server));
|
||||
dispatch(sendMessage(`\x01ACTION ${msg}\x01`, channel, network));
|
||||
} else {
|
||||
return error('Messages can not be empty');
|
||||
}
|
||||
},
|
||||
|
||||
topic({ dispatch, getState, server, channel }, ...newTopic) {
|
||||
topic({ dispatch, getState, network, channel }, ...newTopic) {
|
||||
if (newTopic.length > 0) {
|
||||
dispatch(setTopic(newTopic.join(' '), channel, server));
|
||||
} else if (channel) {
|
||||
const { topic } = getState().channels[server][channel];
|
||||
dispatch(setTopic(newTopic.join(' '), channel, network));
|
||||
return;
|
||||
}
|
||||
if (channel) {
|
||||
const { topic } = getState().channels[network][channel];
|
||||
if (topic) {
|
||||
return text(topic);
|
||||
}
|
||||
@ -88,80 +90,83 @@ export default createCommandMiddleware(COMMAND, {
|
||||
return 'No topic set';
|
||||
},
|
||||
|
||||
msg({ dispatch, server }, target, ...message) {
|
||||
msg({ dispatch, network }, target, ...message) {
|
||||
if (!target) {
|
||||
return error('Missing nick/channel');
|
||||
}
|
||||
|
||||
const msg = message.join(' ');
|
||||
if (msg !== '') {
|
||||
dispatch(sendMessage(message.join(' '), target, server));
|
||||
dispatch(select(server, target));
|
||||
dispatch(sendMessage(message.join(' '), target, network));
|
||||
if (!isChannel(target)) {
|
||||
dispatch(openPrivateChat(network, target));
|
||||
}
|
||||
dispatch(select(network, target));
|
||||
} else {
|
||||
return error('Messages can not be empty');
|
||||
}
|
||||
},
|
||||
|
||||
say({ dispatch, server, channel }, ...message) {
|
||||
say({ dispatch, network, channel }, ...message) {
|
||||
if (!channel) {
|
||||
return error('Messages can only be sent to channels or users');
|
||||
}
|
||||
|
||||
const msg = message.join(' ');
|
||||
if (msg !== '') {
|
||||
dispatch(sendMessage(message.join(' '), channel, server));
|
||||
dispatch(sendMessage(message.join(' '), channel, network));
|
||||
} else {
|
||||
return error('Messages can not be empty');
|
||||
}
|
||||
},
|
||||
|
||||
invite({ dispatch, server, channel, isChannel }, user, inviteChannel) {
|
||||
if (!inviteChannel && !isChannel) {
|
||||
invite({ dispatch, network, channel, inChannel }, user, inviteChannel) {
|
||||
if (!inviteChannel && !inChannel) {
|
||||
return error('This is not a channel');
|
||||
}
|
||||
|
||||
if (user && inviteChannel) {
|
||||
dispatch(invite(user, inviteChannel, server));
|
||||
dispatch(invite(user, inviteChannel, network));
|
||||
} else if (user && channel) {
|
||||
dispatch(invite(user, channel, server));
|
||||
dispatch(invite(user, channel, network));
|
||||
} else {
|
||||
return error('Missing nick');
|
||||
}
|
||||
},
|
||||
|
||||
kick({ dispatch, server, channel, isChannel }, user) {
|
||||
if (!isChannel) {
|
||||
kick({ dispatch, network, channel, inChannel }, user) {
|
||||
if (!inChannel) {
|
||||
return error('This is not a channel');
|
||||
}
|
||||
|
||||
if (user) {
|
||||
dispatch(kick(user, channel, server));
|
||||
dispatch(kick(user, channel, network));
|
||||
} else {
|
||||
return error('Missing nick');
|
||||
}
|
||||
},
|
||||
|
||||
whois({ dispatch, server }, user) {
|
||||
whois({ dispatch, network }, user) {
|
||||
if (user) {
|
||||
dispatch(whois(user, server));
|
||||
dispatch(whois(user, network));
|
||||
} else {
|
||||
return error('Missing nick');
|
||||
}
|
||||
},
|
||||
|
||||
away({ dispatch, server }, ...message) {
|
||||
away({ dispatch, network }, ...message) {
|
||||
const msg = message.join(' ');
|
||||
dispatch(away(msg, server));
|
||||
dispatch(away(msg, network));
|
||||
if (msg !== '') {
|
||||
return 'Away message set';
|
||||
}
|
||||
return 'Away message cleared';
|
||||
},
|
||||
|
||||
raw({ dispatch, server }, ...message) {
|
||||
raw({ dispatch, network }, ...message) {
|
||||
if (message.length > 0 && message[0] !== '') {
|
||||
const cmd = `${message[0].toUpperCase()} ${message.slice(1).join(' ')}`;
|
||||
dispatch(raw(cmd, server));
|
||||
dispatch(raw(cmd, network));
|
||||
return prompt(`=> ${cmd}`);
|
||||
}
|
||||
return [prompt('=> /raw'), error('Missing message')];
|
||||
@ -185,9 +190,6 @@ export default createCommandMiddleware(COMMAND, {
|
||||
},
|
||||
|
||||
[notFoundHandler](ctx, command, ...params) {
|
||||
if (command === command.toUpperCase()) {
|
||||
return this.raw(ctx, command, ...params);
|
||||
}
|
||||
return error(`=> /${command}: No such command`);
|
||||
return this.raw(ctx, command, ...params);
|
||||
}
|
||||
});
|
||||
|
@ -1,25 +1,40 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import React, { Suspense, lazy, useState, useEffect } from 'react';
|
||||
import Route from 'containers/Route';
|
||||
import AppInfo from 'components/AppInfo';
|
||||
import TabList from 'components/TabList';
|
||||
import cn from 'classnames';
|
||||
|
||||
const Modals = lazy(() => import('components/modals'));
|
||||
const Chat = lazy(() => import('containers/Chat'));
|
||||
const Connect = lazy(() => import('containers/Connect'));
|
||||
const Connect = lazy(() =>
|
||||
import(/* webpackChunkName: "connect" */ 'containers/Connect')
|
||||
);
|
||||
const Settings = lazy(() => import('containers/Settings'));
|
||||
|
||||
const App = ({
|
||||
connected,
|
||||
tab,
|
||||
channels,
|
||||
servers,
|
||||
networks,
|
||||
privateChats,
|
||||
showTabList,
|
||||
select,
|
||||
push,
|
||||
hideMenu,
|
||||
newVersionAvailable
|
||||
openModal,
|
||||
newVersionAvailable,
|
||||
hasOpenModals
|
||||
}) => {
|
||||
const [renderModals, setRenderModals] = useState(false);
|
||||
if (!renderModals && hasOpenModals) {
|
||||
setRenderModals(true);
|
||||
}
|
||||
|
||||
const [starting, setStarting] = useState(true);
|
||||
useEffect(() => {
|
||||
setTimeout(() => setStarting(false), 1000);
|
||||
}, []);
|
||||
|
||||
const mainClass = cn('main-container', {
|
||||
'off-canvas': showTabList
|
||||
});
|
||||
@ -32,7 +47,7 @@ const App = ({
|
||||
|
||||
return (
|
||||
<div className="wrap" onClick={handleClick}>
|
||||
{!connected && (
|
||||
{!starting && !connected && (
|
||||
<AppInfo type="error">
|
||||
Connection lost, attempting to reconnect...
|
||||
</AppInfo>
|
||||
@ -47,17 +62,15 @@ const App = ({
|
||||
<TabList
|
||||
tab={tab}
|
||||
channels={channels}
|
||||
servers={servers}
|
||||
networks={networks}
|
||||
privateChats={privateChats}
|
||||
showTabList={showTabList}
|
||||
select={select}
|
||||
push={push}
|
||||
openModal={openModal}
|
||||
/>
|
||||
<div className={mainClass}>
|
||||
<Suspense
|
||||
maxDuration={1000}
|
||||
fallback={<div className="suspense-fallback">...</div>}
|
||||
>
|
||||
<Suspense fallback={<div className="suspense-fallback">...</div>}>
|
||||
<Route name="chat">
|
||||
<Chat />
|
||||
</Route>
|
||||
@ -68,6 +81,11 @@ const App = ({
|
||||
<Settings />
|
||||
</Route>
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={<div className="suspense-modal-fallback">...</div>}
|
||||
>
|
||||
{renderModals && <Modals />}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,12 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { hot, setConfig } from 'react-hot-loader';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import App from 'containers/App';
|
||||
|
||||
setConfig({
|
||||
pureSFC: true
|
||||
});
|
||||
|
||||
const Root = ({ store }) => (
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
|
@ -1,45 +1,82 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import get from 'lodash/get';
|
||||
import { FiPlus, FiUser, FiSettings } from 'react-icons/fi';
|
||||
import Button from 'components/ui/Button';
|
||||
import TabListItem from './TabListItem';
|
||||
import TabListItem from 'containers/TabListItem';
|
||||
import { count } from 'utils';
|
||||
|
||||
export default class TabList extends PureComponent {
|
||||
handleTabClick = (server, target) => this.props.select(server, target);
|
||||
handleTabClick = (network, target) => this.props.select(network, target);
|
||||
|
||||
handleConnectClick = () => this.props.push('/connect');
|
||||
|
||||
handleSettingsClick = () => this.props.push('/settings');
|
||||
|
||||
render() {
|
||||
const { tab, channels, servers, privateChats, showTabList } = this.props;
|
||||
const {
|
||||
tab,
|
||||
channels,
|
||||
networks,
|
||||
privateChats,
|
||||
showTabList,
|
||||
openModal
|
||||
} = this.props;
|
||||
const tabs = [];
|
||||
|
||||
const className = classnames('tablist', {
|
||||
'off-canvas': showTabList
|
||||
});
|
||||
|
||||
channels.forEach(server => {
|
||||
const { address } = server;
|
||||
const srv = servers[address];
|
||||
channels.forEach(network => {
|
||||
const { address } = network;
|
||||
const srv = networks[address];
|
||||
tabs.push(
|
||||
<TabListItem
|
||||
key={address}
|
||||
server={address}
|
||||
network={address}
|
||||
content={srv.name}
|
||||
selected={tab.server === address && !tab.name}
|
||||
connected={srv.status.connected}
|
||||
selected={tab.network === address && !tab.name}
|
||||
connected={srv.connected}
|
||||
onClick={this.handleTabClick}
|
||||
/>
|
||||
);
|
||||
|
||||
server.channels.forEach(name =>
|
||||
const chanCount = count(network.channels, c => c.joined);
|
||||
const chanLimit =
|
||||
get(srv.features, ['CHANLIMIT', '#'], 0) || srv.features.MAXCHANNELS;
|
||||
|
||||
let chanLabel;
|
||||
if (chanLimit > 0) {
|
||||
chanLabel = (
|
||||
<span>
|
||||
<span className="success">{chanCount}</span>/{chanLimit}
|
||||
</span>
|
||||
);
|
||||
} else if (chanCount > 0) {
|
||||
chanLabel = <span className="success">{chanCount}</span>;
|
||||
}
|
||||
|
||||
tabs.push(
|
||||
<div
|
||||
key={`${address}-chans}`}
|
||||
className="tab-label tab-label-channels"
|
||||
onClick={() => openModal('channel', address)}
|
||||
>
|
||||
<span>CHANNELS {chanLabel}</span>
|
||||
<Button title="Join Channel">+</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
network.channels.forEach(({ name, joined }) =>
|
||||
tabs.push(
|
||||
<TabListItem
|
||||
key={address + name}
|
||||
server={address}
|
||||
network={address}
|
||||
target={name}
|
||||
content={name}
|
||||
selected={tab.server === address && tab.name === name}
|
||||
joined={joined}
|
||||
selected={tab.network === address && tab.name === name}
|
||||
onClick={this.handleTabClick}
|
||||
/>
|
||||
)
|
||||
@ -48,7 +85,13 @@ export default class TabList extends PureComponent {
|
||||
if (privateChats[address] && privateChats[address].length > 0) {
|
||||
tabs.push(
|
||||
<div key={`${address}-pm}`} className="tab-label">
|
||||
Private messages
|
||||
<span>
|
||||
DIRECT MESSAGES{' '}
|
||||
<span style={{ color: '#6bb758' }}>
|
||||
{privateChats[address].length}
|
||||
</span>
|
||||
</span>
|
||||
{/* <Button>+</Button> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -56,10 +99,10 @@ export default class TabList extends PureComponent {
|
||||
tabs.push(
|
||||
<TabListItem
|
||||
key={address + nick}
|
||||
server={address}
|
||||
network={address}
|
||||
target={nick}
|
||||
content={nick}
|
||||
selected={tab.server === address && tab.name === nick}
|
||||
selected={tab.network === address && tab.name === nick}
|
||||
onClick={this.handleTabClick}
|
||||
/>
|
||||
)
|
||||
@ -71,9 +114,17 @@ export default class TabList extends PureComponent {
|
||||
<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} />
|
||||
<Button
|
||||
icon={FiPlus}
|
||||
aria-label="Connect"
|
||||
onClick={this.handleConnectClick}
|
||||
/>
|
||||
<Button icon={FiUser} aria-label="User" />
|
||||
<Button
|
||||
icon={FiSettings}
|
||||
aria-label="Settings"
|
||||
onClick={this.handleSettingsClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,26 +1,46 @@
|
||||
import React, { memo } from 'react';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
function splitContent(content) {
|
||||
let start = 0;
|
||||
while (content[start] === '#') {
|
||||
start++;
|
||||
}
|
||||
|
||||
if (start > 0) {
|
||||
return [content.slice(0, start), content.slice(start)];
|
||||
}
|
||||
return [null, content];
|
||||
}
|
||||
|
||||
const TabListItem = ({
|
||||
target,
|
||||
content,
|
||||
server,
|
||||
network,
|
||||
selected,
|
||||
connected,
|
||||
joined,
|
||||
error,
|
||||
onClick
|
||||
}) => {
|
||||
const className = classnames({
|
||||
'tab-server': !target,
|
||||
'tab-network': !target,
|
||||
success: !target && connected,
|
||||
error: !target && !connected,
|
||||
error: (!target && !connected) || (!joined && error),
|
||||
disabled: !!target && !error && joined === false,
|
||||
selected
|
||||
});
|
||||
|
||||
const [prefix, name] = splitContent(content);
|
||||
|
||||
return (
|
||||
<p className={className} onClick={() => onClick(server, target)}>
|
||||
<span className="tab-content">{content}</span>
|
||||
<p className={className} onClick={() => onClick(network, target)}>
|
||||
<span className="tab-content">
|
||||
{prefix && <span className="tab-prefix">{prefix}</span>}
|
||||
{name}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(TabListItem);
|
||||
export default TabListItem;
|
||||
|
72
client/js/components/Text.js
Normal file
72
client/js/components/Text.js
Normal file
@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import stringToRGB from 'utils/color';
|
||||
|
||||
function nickStyle(nick, color) {
|
||||
const style = {
|
||||
fontWeight: 400
|
||||
};
|
||||
|
||||
if (color) {
|
||||
style.color = stringToRGB(nick);
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
function renderBlock(block, coloredNick, key) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return block.text;
|
||||
|
||||
case 'link':
|
||||
return (
|
||||
<a target="_blank" rel="noopener noreferrer" href={block.url} key={key}>
|
||||
{block.text}
|
||||
</a>
|
||||
);
|
||||
|
||||
case 'format':
|
||||
return (
|
||||
<span style={block.style} key={key}>
|
||||
{block.text}
|
||||
</span>
|
||||
);
|
||||
|
||||
case 'nick':
|
||||
return (
|
||||
<span
|
||||
className="message-sender"
|
||||
style={nickStyle(block.text, coloredNick)}
|
||||
key={key}
|
||||
>
|
||||
{block.text}
|
||||
</span>
|
||||
);
|
||||
|
||||
case 'events':
|
||||
return (
|
||||
<span className="message-events-more" key={key}>
|
||||
{block.text}
|
||||
</span>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const Text = ({ children, coloredNick }) => {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
if (children.length > 1) {
|
||||
let key = 0;
|
||||
return children.map(block => renderBlock(block, coloredNick, key++));
|
||||
}
|
||||
if (children.length === 1) {
|
||||
return renderBlock(children[0], coloredNick);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export default Text;
|
155
client/js/components/modals/AddChannel.js
Normal file
155
client/js/components/modals/AddChannel.js
Normal file
@ -0,0 +1,155 @@
|
||||
import React, { memo, useState, useEffect, useRef } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { FiUsers, FiX } from 'react-icons/fi';
|
||||
import Text from 'components/Text';
|
||||
import useModal from 'components/modals/useModal';
|
||||
import Button from 'components/ui/Button';
|
||||
import { join } from 'state/channels';
|
||||
import { searchChannels } from 'state/channelSearch';
|
||||
import { linkify } from 'utils';
|
||||
import colorify from 'utils/colorify';
|
||||
|
||||
const Channel = memo(({ network, name, topic, userCount, joined }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = () => dispatch(join([name], network));
|
||||
|
||||
return (
|
||||
<div className="modal-channel-result">
|
||||
<div className="modal-channel-result-header">
|
||||
<h2 className="modal-channel-name" onClick={handleClick}>
|
||||
{name}
|
||||
</h2>
|
||||
<FiUsers />
|
||||
<span className="modal-channel-users">{userCount}</span>
|
||||
{joined ? (
|
||||
<span style={{ color: '#6bb758' }}>Joined</span>
|
||||
) : (
|
||||
<Button
|
||||
className="modal-channel-button-join"
|
||||
category="normal"
|
||||
onClick={handleClick}
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="modal-channel-topic">
|
||||
<Text>{colorify(linkify(topic))}</Text>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const AddChannel = () => {
|
||||
const [modal, network, closeModal] = useModal('channel');
|
||||
|
||||
const channels = useSelector(state => state.channels);
|
||||
const search = useSelector(state => state.channelSearch);
|
||||
const dispatch = useDispatch();
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
const inputEl = useRef();
|
||||
const resultsEl = useRef();
|
||||
const prevSearch = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (modal.isOpen) {
|
||||
dispatch(searchChannels(network, ''));
|
||||
setTimeout(() => inputEl.current.focus(), 0);
|
||||
} else {
|
||||
prevSearch.current = '';
|
||||
setQ('');
|
||||
}
|
||||
}, [modal.isOpen]);
|
||||
|
||||
const handleSearch = e => {
|
||||
let nextQ = e.target.value.trim().toLowerCase();
|
||||
setQ(nextQ);
|
||||
|
||||
if (nextQ !== q) {
|
||||
resultsEl.current.scrollTop = 0;
|
||||
|
||||
while (nextQ.charAt(0) === '#') {
|
||||
nextQ = nextQ.slice(1);
|
||||
}
|
||||
|
||||
if (nextQ !== prevSearch.current) {
|
||||
prevSearch.current = nextQ;
|
||||
dispatch(searchChannels(network, nextQ));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKey = e => {
|
||||
if (e.key === 'Enter') {
|
||||
let channel = e.target.value.trim();
|
||||
|
||||
if (channel !== '') {
|
||||
closeModal(false);
|
||||
|
||||
if (channel.charAt(0) !== '#') {
|
||||
channel = `#${channel}`;
|
||||
}
|
||||
|
||||
dispatch(join([channel], network));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () =>
|
||||
dispatch(searchChannels(network, q, search.results.length));
|
||||
|
||||
let hasMore = !search.end;
|
||||
if (hasMore) {
|
||||
if (search.results.length < 10) {
|
||||
hasMore = false;
|
||||
} else if (
|
||||
search.results.length > 10 &&
|
||||
(search.results.length - 10) % 50 !== 0
|
||||
) {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal {...modal}>
|
||||
<div className="modal-channel-input-wrap">
|
||||
<input
|
||||
ref={inputEl}
|
||||
type="text"
|
||||
value={q}
|
||||
placeholder="Enter channel name"
|
||||
onKeyDown={handleKey}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<Button
|
||||
icon={FiX}
|
||||
className="modal-close modal-channel-close"
|
||||
onClick={closeModal}
|
||||
/>
|
||||
</div>
|
||||
<div ref={resultsEl} className="modal-channel-results">
|
||||
{search.results.map(channel => (
|
||||
<Channel
|
||||
key={`${network} ${channel.name}`}
|
||||
network={network}
|
||||
joined={channels[network]?.[channel.name]?.joined}
|
||||
{...channel}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<Button
|
||||
className="modal-channel-button-more"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddChannel;
|
26
client/js/components/modals/Confirm.js
Normal file
26
client/js/components/modals/Confirm.js
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import useModal from 'components/modals/useModal';
|
||||
import Button from 'components/ui/Button';
|
||||
|
||||
const Confirm = () => {
|
||||
const [modal, payload, closeModal] = useModal('confirm');
|
||||
const { question, confirmation, onConfirm } = payload;
|
||||
|
||||
const handleConfirm = () => {
|
||||
closeModal(false);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal {...modal}>
|
||||
<p>{question}</p>
|
||||
<Button onClick={handleConfirm}>{confirmation || 'OK'}</Button>
|
||||
<Button category="normal" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Confirm;
|
30
client/js/components/modals/Topic.js
Normal file
30
client/js/components/modals/Topic.js
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FiX } from 'react-icons/fi';
|
||||
import Text from 'components/Text';
|
||||
import Button from 'components/ui/Button';
|
||||
import useModal from 'components/modals/useModal';
|
||||
import { getSelectedChannel } from 'state/channels';
|
||||
import { linkify } from 'utils';
|
||||
import colorify from 'utils/colorify';
|
||||
|
||||
const Topic = () => {
|
||||
const [modal, channel, closeModal] = useModal('topic');
|
||||
|
||||
const topic = useSelector(state => getSelectedChannel(state)?.topic);
|
||||
|
||||
return (
|
||||
<Modal {...modal}>
|
||||
<div className="modal-header">
|
||||
<h2>Topic in {channel}</h2>
|
||||
<Button icon={FiX} className="modal-close" onClick={closeModal} />
|
||||
</div>
|
||||
<p className="modal-content">
|
||||
<Text>{colorify(linkify(topic))}</Text>
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Topic;
|
14
client/js/components/modals/index.js
Normal file
14
client/js/components/modals/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
import React, { memo } from 'react';
|
||||
import AddChannel from 'components/modals/AddChannel';
|
||||
import Confirm from 'components/modals/Confirm';
|
||||
import Topic from 'components/modals/Topic';
|
||||
|
||||
const Modals = () => (
|
||||
<>
|
||||
<AddChannel />
|
||||
<Confirm />
|
||||
<Topic />
|
||||
</>
|
||||
);
|
||||
|
||||
export default memo(Modals, () => true);
|
46
client/js/components/modals/useModal.js
Normal file
46
client/js/components/modals/useModal.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { closeModal } from 'state/modals';
|
||||
|
||||
Modal.setAppElement('#root');
|
||||
|
||||
const defaultPayload = {};
|
||||
|
||||
export default function useModal(name) {
|
||||
const isOpen = useSelector(state => state.modals[name]?.isOpen || false);
|
||||
const payload = useSelector(
|
||||
state => state.modals[name]?.payload || defaultPayload
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleRequestClose = useCallback(
|
||||
(dismissed = true) => {
|
||||
dispatch(closeModal(name));
|
||||
|
||||
if (dismissed && payload.onDismiss) {
|
||||
payload.onDismiss();
|
||||
}
|
||||
},
|
||||
[payload.onDismiss]
|
||||
);
|
||||
|
||||
const modalProps = {
|
||||
isOpen,
|
||||
contentLabel: name,
|
||||
onRequestClose: handleRequestClose,
|
||||
className: {
|
||||
base: `modal modal-${name}`,
|
||||
afterOpen: 'modal-opening',
|
||||
beforeClose: 'modal-closing'
|
||||
},
|
||||
overlayClassName: {
|
||||
base: 'modal-overlay',
|
||||
afterOpen: 'modal-overlay-opening',
|
||||
beforeClose: 'modal-overlay-closing'
|
||||
},
|
||||
closeTimeoutMS: 200
|
||||
};
|
||||
|
||||
return [modalProps, payload, handleRequestClose];
|
||||
}
|
@ -11,40 +11,40 @@ export default class Chat extends Component {
|
||||
const { tab, part, closePrivateChat, disconnect } = this.props;
|
||||
|
||||
if (isChannel(tab)) {
|
||||
part([tab.name], tab.server);
|
||||
part([tab.name], tab.network);
|
||||
} else if (tab.name) {
|
||||
closePrivateChat(tab.server, tab.name);
|
||||
closePrivateChat(tab.network, tab.name);
|
||||
} else {
|
||||
disconnect(tab.server);
|
||||
disconnect(tab.network);
|
||||
}
|
||||
};
|
||||
|
||||
handleSearch = phrase => {
|
||||
const { tab, searchMessages } = this.props;
|
||||
if (isChannel(tab)) {
|
||||
searchMessages(tab.server, tab.name, phrase);
|
||||
searchMessages(tab.network, tab.name, phrase);
|
||||
}
|
||||
};
|
||||
|
||||
handleNickClick = nick => {
|
||||
const { tab, openPrivateChat, select } = this.props;
|
||||
openPrivateChat(tab.server, nick);
|
||||
select(tab.server, nick);
|
||||
openPrivateChat(tab.network, nick);
|
||||
select(tab.network, nick);
|
||||
};
|
||||
|
||||
handleTitleChange = title => {
|
||||
const { setServerName, tab } = this.props;
|
||||
setServerName(title, tab.server);
|
||||
const { setNetworkName, tab } = this.props;
|
||||
setNetworkName(title, tab.network);
|
||||
};
|
||||
|
||||
handleNickChange = nick => {
|
||||
const { setNick, tab } = this.props;
|
||||
setNick(nick, tab.server, true);
|
||||
setNick(nick, tab.network, true);
|
||||
};
|
||||
|
||||
handleNickEditDone = nick => {
|
||||
const { setNick, tab } = this.props;
|
||||
setNick(nick, tab.server);
|
||||
setNick(nick, tab.network);
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -57,7 +57,7 @@ export default class Chat extends Component {
|
||||
nick,
|
||||
search,
|
||||
showUserList,
|
||||
status,
|
||||
error,
|
||||
tab,
|
||||
title,
|
||||
users,
|
||||
@ -65,6 +65,7 @@ export default class Chat extends Component {
|
||||
addFetchedMessages,
|
||||
fetchMessages,
|
||||
inputActions,
|
||||
openModal,
|
||||
runCommand,
|
||||
sendMessage,
|
||||
toggleSearch,
|
||||
@ -76,16 +77,17 @@ export default class Chat extends Component {
|
||||
} else if (tab.name) {
|
||||
chatClass = 'chat-private';
|
||||
} else {
|
||||
chatClass = 'chat-server';
|
||||
chatClass = 'chat-network';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={chatClass}>
|
||||
<ChatTitle
|
||||
channel={channel}
|
||||
status={status}
|
||||
error={error}
|
||||
tab={tab}
|
||||
title={title}
|
||||
openModal={openModal}
|
||||
onCloseClick={this.handleCloseClick}
|
||||
onTitleChange={this.handleTitleChange}
|
||||
onToggleSearch={toggleSearch}
|
||||
@ -97,6 +99,7 @@ export default class Chat extends Component {
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
messages={messages}
|
||||
tab={tab}
|
||||
hideTopDate={search.show}
|
||||
onAddMore={addFetchedMessages}
|
||||
onFetchMore={fetchMessages}
|
||||
onNickClick={this.handleNickClick}
|
||||
|
@ -1,14 +1,17 @@
|
||||
import React, { memo } from 'react';
|
||||
import Navicon from 'containers/Navicon';
|
||||
import { FiUsers, FiSearch, FiX } from 'react-icons/fi';
|
||||
import Navicon from 'components/ui/Navicon';
|
||||
import Button from 'components/ui/Button';
|
||||
import Editable from 'components/ui/Editable';
|
||||
import { isValidServerName } from 'state/servers';
|
||||
import { isChannel, linkify } from 'utils';
|
||||
import { isValidNetworkName } from 'state/networks';
|
||||
import { isChannel } from 'utils';
|
||||
|
||||
const ChatTitle = ({
|
||||
status,
|
||||
error,
|
||||
title,
|
||||
tab,
|
||||
channel,
|
||||
openModal,
|
||||
onTitleChange,
|
||||
onToggleSearch,
|
||||
onToggleUserList,
|
||||
@ -23,14 +26,9 @@ const ChatTitle = ({
|
||||
closeTitle = 'Disconnect';
|
||||
}
|
||||
|
||||
let serverError = null;
|
||||
if (!tab.name && status.error) {
|
||||
serverError = (
|
||||
<span className="chat-topic error">
|
||||
Error:
|
||||
{status.error}
|
||||
</span>
|
||||
);
|
||||
let networkError = null;
|
||||
if (!tab.name && error) {
|
||||
networkError = <span className="chat-topic error">Error: {error}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -41,30 +39,46 @@ const ChatTitle = ({
|
||||
className="chat-title"
|
||||
editable={!tab.name}
|
||||
value={title}
|
||||
validate={isValidServerName}
|
||||
validate={isValidNetworkName}
|
||||
onChange={onTitleChange}
|
||||
>
|
||||
<span className="chat-title">{title}</span>
|
||||
</Editable>
|
||||
<div className="chat-topic-wrap">
|
||||
<span className="chat-topic">
|
||||
{channel && linkify(channel.topic)}
|
||||
</span>
|
||||
{serverError}
|
||||
{channel?.topic && (
|
||||
<span
|
||||
className="chat-topic"
|
||||
onClick={() => openModal('topic', channel.name)}
|
||||
>
|
||||
{channel.topic}
|
||||
</span>
|
||||
)}
|
||||
{networkError}
|
||||
</div>
|
||||
<i className="icon-search" title="Search" onClick={onToggleSearch} />
|
||||
<i
|
||||
className="icon-cancel button-leave"
|
||||
{tab.name && (
|
||||
<Button
|
||||
icon={FiSearch}
|
||||
title="Search"
|
||||
aria-label="Search"
|
||||
onClick={onToggleSearch}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon={FiX}
|
||||
title={closeTitle}
|
||||
aria-label={closeTitle}
|
||||
onClick={onCloseClick}
|
||||
/>
|
||||
<i className="icon-user button-userlist" onClick={onToggleUserList} />
|
||||
<Button
|
||||
icon={FiUsers}
|
||||
className="button-userlist"
|
||||
aria-label="Users"
|
||||
onClick={onToggleUserList}
|
||||
/>
|
||||
</div>
|
||||
<div className="userlist-bar">
|
||||
<i className="icon-user" />
|
||||
<span className="chat-usercount">
|
||||
{channel && channel.users.length}
|
||||
</span>
|
||||
<FiUsers />
|
||||
{channel?.users.length}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,16 +1,25 @@
|
||||
import React, { memo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Text from 'components/Text';
|
||||
import stringToRGB from 'utils/color';
|
||||
|
||||
const Message = ({ message, coloredNick, style, onNickClick }) => {
|
||||
const Message = ({ message, coloredNick, onNickClick }) => {
|
||||
const className = classnames('message', {
|
||||
[`message-${message.type}`]: message.type
|
||||
});
|
||||
|
||||
style = {
|
||||
...style,
|
||||
paddingLeft: `${window.messageIndent + 15}px`,
|
||||
textIndent: `-${window.messageIndent}px`
|
||||
if (message.type === 'date') {
|
||||
return (
|
||||
<div className={className}>
|
||||
{message.content}
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const style = {
|
||||
paddingLeft: `${message.indent + 15}px`,
|
||||
textIndent: `-${message.indent}px`
|
||||
};
|
||||
|
||||
const senderStyle = {};
|
||||
@ -30,7 +39,10 @@ const Message = ({ message, coloredNick, style, onNickClick }) => {
|
||||
{message.from}
|
||||
</span>
|
||||
)}
|
||||
<span> {message.content}</span>
|
||||
<span>
|
||||
{' '}
|
||||
<Text coloredNick={coloredNick}>{message.content}</Text>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
@ -2,23 +2,41 @@ import React, { PureComponent, createRef } from 'react';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { formatDate, measureScrollBarWidth } from 'utils';
|
||||
import { getScrollPos, saveScrollPos } from 'utils/scrollPosition';
|
||||
import { windowHeight } from 'utils/size';
|
||||
import Message from './Message';
|
||||
|
||||
const fetchThreshold = 600;
|
||||
// The amount of time in ms that needs to pass without any
|
||||
// scroll events happening before adding messages to the top,
|
||||
// this is done to prevent the scroll from jumping all over the place
|
||||
const scrollbackDebounce = 100;
|
||||
const scrollbackDebounce = 150;
|
||||
|
||||
const scrollBarWidth = `${measureScrollBarWidth()}px`;
|
||||
|
||||
const hasSameLastMessage = (m1, m2) => {
|
||||
if (m1.length === 0 || m2.length === 0) {
|
||||
if (m1.length === 0 && m2.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return m1[m1.length - 1].id === m2[m2.length - 1].id;
|
||||
};
|
||||
|
||||
export default class MessageBox extends PureComponent {
|
||||
state = { topDate: '' };
|
||||
|
||||
list = createRef();
|
||||
|
||||
outer = createRef();
|
||||
|
||||
addMore = debounce(() => {
|
||||
const { tab, onAddMore } = this.props;
|
||||
this.ready = true;
|
||||
onAddMore(tab.server, tab.name);
|
||||
onAddMore(tab.network, tab.name);
|
||||
}, scrollbackDebounce);
|
||||
|
||||
constructor(props) {
|
||||
@ -27,19 +45,9 @@ export default class MessageBox extends PureComponent {
|
||||
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) {
|
||||
const { messages } = this.props;
|
||||
|
||||
if (prevProps.tab !== this.props.tab) {
|
||||
this.loadScrollPos(true);
|
||||
}
|
||||
@ -47,8 +55,11 @@ export default class MessageBox extends PureComponent {
|
||||
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);
|
||||
} else if (
|
||||
this.bottom &&
|
||||
!hasSameLastMessage(messages, prevProps.messages)
|
||||
) {
|
||||
this.list.current.scrollToItem(messages.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,7 +80,7 @@ export default class MessageBox extends PureComponent {
|
||||
if (prevProps.messages[0] !== this.props.messages[0]) {
|
||||
const { messages, hasMoreMessages } = this.props;
|
||||
|
||||
if (prevProps.tab === this.props.tab) {
|
||||
if (prevProps.tab === this.props.tab && prevProps.messages.length > 0) {
|
||||
const addedMessages = messages.length - prevProps.messages.length;
|
||||
let addedHeight = 0;
|
||||
for (let i = 0; i < addedMessages; i++) {
|
||||
@ -98,7 +109,8 @@ export default class MessageBox extends PureComponent {
|
||||
return 100;
|
||||
}
|
||||
return 7;
|
||||
} else if (index === messages.length + 1) {
|
||||
}
|
||||
if (index === messages.length + 1) {
|
||||
return 7;
|
||||
}
|
||||
return messages[index - 1].height;
|
||||
@ -109,7 +121,8 @@ export default class MessageBox extends PureComponent {
|
||||
|
||||
if (index === 0) {
|
||||
return 'top';
|
||||
} else if (index === messages.length + 1) {
|
||||
}
|
||||
if (index === messages.length + 1) {
|
||||
return 'bottom';
|
||||
}
|
||||
return messages[index - 1].id;
|
||||
@ -117,12 +130,14 @@ export default class MessageBox extends PureComponent {
|
||||
|
||||
updateScrollKey = () => {
|
||||
const { tab } = this.props;
|
||||
this.scrollKey = `msg:${tab.server}:${tab.name}`;
|
||||
this.scrollKey = `msg:${tab.network}:${tab.name}`;
|
||||
return this.scrollKey;
|
||||
};
|
||||
|
||||
loadScrollPos = scroll => {
|
||||
const pos = getScrollPos(this.updateScrollKey());
|
||||
const { messages } = this.props;
|
||||
|
||||
if (pos >= 0) {
|
||||
this.bottom = false;
|
||||
if (scroll) {
|
||||
@ -133,7 +148,17 @@ export default class MessageBox extends PureComponent {
|
||||
} else {
|
||||
this.bottom = true;
|
||||
if (scroll) {
|
||||
this.list.current.scrollToItem(this.props.messages.length + 1);
|
||||
this.list.current.scrollToItem(messages.length + 1);
|
||||
} else if (messages.length > 0) {
|
||||
let totalHeight = 14;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
totalHeight += messages[i].height;
|
||||
}
|
||||
|
||||
const messageBoxHeight = windowHeight() - 100;
|
||||
if (totalHeight > messageBoxHeight) {
|
||||
this.initialScrollTop = totalHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -142,7 +167,7 @@ export default class MessageBox extends PureComponent {
|
||||
if (this.bottom) {
|
||||
saveScrollPos(this.scrollKey, -1);
|
||||
} else {
|
||||
saveScrollPos(this.scrollKey, this.outer.current.scrollTop);
|
||||
saveScrollPos(this.scrollKey, this.scrollTop);
|
||||
}
|
||||
};
|
||||
|
||||
@ -172,9 +197,21 @@ export default class MessageBox extends PureComponent {
|
||||
|
||||
const { clientHeight, scrollHeight } = this.outer.current;
|
||||
|
||||
this.scrollTop = scrollOffset;
|
||||
this.bottom = scrollOffset + clientHeight >= scrollHeight - 20;
|
||||
};
|
||||
|
||||
handleItemsRendered = ({ visibleStartIndex }) => {
|
||||
const startIndex = visibleStartIndex === 0 ? 0 : visibleStartIndex - 1;
|
||||
const firstVisibleMessage = this.props.messages[startIndex];
|
||||
|
||||
if (firstVisibleMessage && firstVisibleMessage.date) {
|
||||
this.setState({ topDate: formatDate(firstVisibleMessage.date) });
|
||||
} else {
|
||||
this.setState({ topDate: '' });
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseDown = () => {
|
||||
this.mouseDown = true;
|
||||
};
|
||||
@ -185,7 +222,7 @@ export default class MessageBox extends PureComponent {
|
||||
if (this.shouldAdd) {
|
||||
const { tab, onAddMore } = this.props;
|
||||
this.shouldAdd = false;
|
||||
onAddMore(tab.server, tab.name);
|
||||
onAddMore(tab.network, tab.name);
|
||||
}
|
||||
};
|
||||
|
||||
@ -201,7 +238,8 @@ export default class MessageBox extends PureComponent {
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} else if (index === messages.length + 1) {
|
||||
}
|
||||
if (index === messages.length + 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -209,22 +247,38 @@ export default class MessageBox extends PureComponent {
|
||||
const message = messages[index - 1];
|
||||
|
||||
return (
|
||||
<Message
|
||||
message={message}
|
||||
coloredNick={coloredNicks}
|
||||
style={style}
|
||||
onNickClick={onNickClick}
|
||||
/>
|
||||
<div style={style}>
|
||||
<Message
|
||||
message={message}
|
||||
coloredNick={coloredNicks}
|
||||
onNickClick={onNickClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { messages, hideTopDate } = this.props;
|
||||
const { topDate } = this.state;
|
||||
|
||||
const dateContainerStyle = {
|
||||
right: scrollBarWidth
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="messagebox"
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="messagebox-topdate-container"
|
||||
style={dateContainerStyle}
|
||||
>
|
||||
{!hideTopDate && topDate && (
|
||||
<span className="messagebox-topdate">{topDate}</span>
|
||||
)}
|
||||
</div>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
@ -232,12 +286,13 @@ export default class MessageBox extends PureComponent {
|
||||
outerRef={this.outer}
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={this.props.messages.length + 2}
|
||||
itemCount={messages.length + 2}
|
||||
itemKey={this.getItemKey}
|
||||
itemSize={this.getRowHeight}
|
||||
estimatedItemSize={32}
|
||||
initialScrollOffset={this.initialScrollTop}
|
||||
onScroll={this.handleScroll}
|
||||
onItemsRendered={this.handleItemsRendered}
|
||||
className="messagebox-window"
|
||||
overscanCount={5}
|
||||
>
|
||||
|
@ -21,9 +21,9 @@ const MessageInput = ({
|
||||
const handleKey = e => {
|
||||
if (e.key === 'Enter' && e.target.value) {
|
||||
if (e.target.value[0] === '/') {
|
||||
onCommand(e.target.value, tab.name, tab.server);
|
||||
onCommand(e.target.value, tab.name, tab.network);
|
||||
} else if (tab.name) {
|
||||
onMessage(e.target.value, tab.name, tab.server);
|
||||
onMessage(e.target.value, tab.name, tab.network);
|
||||
}
|
||||
|
||||
add(e.target.value);
|
||||
|
@ -1,17 +1,15 @@
|
||||
import React, { memo, useRef, useEffect } from 'react';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import SearchResult from './SearchResult';
|
||||
|
||||
const Search = ({ search, onSearch }) => {
|
||||
const inputEl = useRef();
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (search.show) {
|
||||
inputEl.current.focus();
|
||||
}
|
||||
},
|
||||
[search.show]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (search.show) {
|
||||
inputEl.current.focus();
|
||||
}
|
||||
}, [search.show]);
|
||||
|
||||
const style = {
|
||||
display: search.show ? 'block' : 'none'
|
||||
@ -25,7 +23,7 @@ const Search = ({ search, onSearch }) => {
|
||||
return (
|
||||
<div className="search" style={style}>
|
||||
<div className="search-input-wrap">
|
||||
<i className="icon-search" />
|
||||
<FiSearch className="search-input-icon" />
|
||||
<input
|
||||
ref={inputEl}
|
||||
className="search-input"
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { memo } from 'react';
|
||||
import Text from 'components/Text';
|
||||
import { timestamp, linkify } from 'utils';
|
||||
|
||||
const SearchResult = ({ result }) => {
|
||||
@ -16,7 +17,10 @@ const SearchResult = ({ result }) => {
|
||||
{' '}
|
||||
<span className="message-sender">{result.from}</span>
|
||||
</span>
|
||||
<span> {linkify(result.content)}</span>
|
||||
<span>
|
||||
{' '}
|
||||
<Text>{linkify(result.content)}</Text>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
@ -28,7 +28,8 @@ export default class UserList extends PureComponent {
|
||||
|
||||
if (index === 0) {
|
||||
return 12;
|
||||
} else if (index === users.length + 1) {
|
||||
}
|
||||
if (index === users.length + 1) {
|
||||
return 10;
|
||||
}
|
||||
return 24;
|
||||
@ -39,7 +40,8 @@ export default class UserList extends PureComponent {
|
||||
|
||||
if (index === 0) {
|
||||
return 'top';
|
||||
} else if (index === users.length + 1) {
|
||||
}
|
||||
if (index === users.length + 1) {
|
||||
return 'bottom';
|
||||
}
|
||||
return index;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { Component } from 'react';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Form, withFormik } from 'formik';
|
||||
import Navicon from 'containers/Navicon';
|
||||
import { FiMoreHorizontal } from 'react-icons/fi';
|
||||
import Navicon from 'components/ui/Navicon';
|
||||
import Button from 'components/ui/Button';
|
||||
import Checkbox from 'components/ui/formik/Checkbox';
|
||||
import TextInput from 'components/ui/TextInput';
|
||||
@ -13,6 +14,26 @@ const getSortedDefaultChannels = createSelector(
|
||||
channels => channels.split(',').sort()
|
||||
);
|
||||
|
||||
const transformChannels = channels => {
|
||||
const comma = channels[channels.length - 1] === ',';
|
||||
|
||||
channels = channels
|
||||
.split(',')
|
||||
.map(channel => {
|
||||
channel = channel.trim();
|
||||
if (channel) {
|
||||
if (isValidChannel(channel, false) && channel[0] !== '#') {
|
||||
channel = `#${channel}`;
|
||||
}
|
||||
}
|
||||
return channel;
|
||||
})
|
||||
.filter(s => s)
|
||||
.join(',');
|
||||
|
||||
return comma ? `${channels},` : channels;
|
||||
};
|
||||
|
||||
class Connect extends Component {
|
||||
state = {
|
||||
showOptionals: false
|
||||
@ -20,10 +41,10 @@ class Connect extends Component {
|
||||
|
||||
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);
|
||||
if (e.target.checked && values.port === '6667') {
|
||||
setFieldValue('port', '6697', false);
|
||||
} else if (!e.target.checked && values.port === '6697') {
|
||||
setFieldValue('port', '6667', false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -35,14 +56,31 @@ class Connect extends Component {
|
||||
const { hexIP } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<div className="connect-section">
|
||||
<h2>SASL</h2>
|
||||
<TextInput name="account" />
|
||||
<TextInput name="password" type="password" noTrim />
|
||||
</div>
|
||||
{!hexIP && <TextInput name="username" />}
|
||||
<TextInput name="password" type="password" />
|
||||
<TextInput name="realname" />
|
||||
</div>
|
||||
<TextInput
|
||||
name="serverPassword"
|
||||
label="Server Password"
|
||||
type="password"
|
||||
noTrim
|
||||
/>
|
||||
<TextInput name="realname" noTrim />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
transformPort = port => {
|
||||
if (!port) {
|
||||
return this.props.values.tls ? '6697' : '6667';
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { defaults, values } = this.props;
|
||||
const { readOnly, showDetails } = defaults;
|
||||
@ -70,11 +108,17 @@ class Connect extends Component {
|
||||
form = (
|
||||
<Form className="connect-form">
|
||||
<h1>Connect</h1>
|
||||
<TextInput name="name" autoCapitalize="words" />
|
||||
<TextInput name="name" autoCapitalize="words" noTrim />
|
||||
<div className="connect-form-address">
|
||||
<TextInput name="host" noError />
|
||||
<TextInput name="port" type="number" noError />
|
||||
<TextInput
|
||||
name="port"
|
||||
type="number"
|
||||
blurTransform={this.transformPort}
|
||||
noError
|
||||
/>
|
||||
<Checkbox
|
||||
classNameLabel="connect-form-ssl"
|
||||
name="tls"
|
||||
label="SSL"
|
||||
topLabel
|
||||
@ -84,9 +128,14 @@ class Connect extends Component {
|
||||
<Error name="host" />
|
||||
<Error name="port" />
|
||||
<TextInput name="nick" />
|
||||
<TextInput name="channels" />
|
||||
<TextInput name="channels" transform={transformChannels} />
|
||||
{this.state.showOptionals && this.renderOptionals()}
|
||||
<i className="icon-ellipsis" onClick={this.handleShowClick} />
|
||||
<Button
|
||||
className="connect-form-button-optionals"
|
||||
icon={FiMoreHorizontal}
|
||||
aria-label="Show more"
|
||||
onClick={this.handleShowClick}
|
||||
/>
|
||||
<Button type="submit">Connect</Button>
|
||||
</Form>
|
||||
);
|
||||
@ -103,33 +152,43 @@ class Connect extends Component {
|
||||
|
||||
export default withFormik({
|
||||
enableReinitialize: true,
|
||||
mapPropsToValues: ({ defaults }) => {
|
||||
let port = 6667;
|
||||
if (defaults.port) {
|
||||
({ port } = defaults);
|
||||
mapPropsToValues: ({ defaults, query }) => {
|
||||
let port = '6667';
|
||||
if (query.port || defaults.port) {
|
||||
port = query.port || defaults.port;
|
||||
} else if (defaults.ssl) {
|
||||
port = 6697;
|
||||
port = '6697';
|
||||
}
|
||||
|
||||
let { channels } = query;
|
||||
if (channels) {
|
||||
channels = transformChannels(channels);
|
||||
}
|
||||
|
||||
let ssl;
|
||||
if (query.ssl === 'true') {
|
||||
ssl = true;
|
||||
} else if (query.ssl === 'false') {
|
||||
ssl = false;
|
||||
} else {
|
||||
ssl = defaults.ssl || false;
|
||||
}
|
||||
|
||||
return {
|
||||
name: defaults.name,
|
||||
host: defaults.host,
|
||||
name: query.name || defaults.name,
|
||||
host: query.host || defaults.host,
|
||||
port,
|
||||
nick: '',
|
||||
channels: defaults.channels.join(','),
|
||||
username: '',
|
||||
password: defaults.password ? ' ' : '',
|
||||
realname: '',
|
||||
tls: defaults.ssl
|
||||
nick: query.nick || localStorage.lastNick || '',
|
||||
channels: channels || defaults.channels.join(','),
|
||||
account: '',
|
||||
password: '',
|
||||
username: query.username || '',
|
||||
serverPassword: defaults.serverPassword ? ' ' : '',
|
||||
realname: query.realname || localStorage.lastRealname || '',
|
||||
tls: ssl
|
||||
};
|
||||
},
|
||||
validate: values => {
|
||||
Object.keys(values).forEach(k => {
|
||||
if (typeof values[k] === 'string') {
|
||||
values[k] = values[k].trim();
|
||||
}
|
||||
});
|
||||
|
||||
const errors = {};
|
||||
|
||||
if (!values.host) {
|
||||
@ -138,9 +197,7 @@ export default withFormik({
|
||||
errors.host = 'Invalid host';
|
||||
}
|
||||
|
||||
if (!values.port) {
|
||||
values.port = values.tls ? 6697 : 6667;
|
||||
} else if (!isInt(values.port, 1, 65535)) {
|
||||
if (!isInt(values.port, 1, 65535)) {
|
||||
errors.port = 'Invalid port';
|
||||
}
|
||||
|
||||
@ -154,29 +211,24 @@ export default withFormik({
|
||||
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(',');
|
||||
const channels = values.channels.split(',');
|
||||
for (let i = channels.length - 1; i >= 0; i--) {
|
||||
if (i === channels.length - 1 && channels[i] === '') {
|
||||
/* eslint-disable-next-line no-continue */
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isValidChannel(channels[i])) {
|
||||
errors.channels = 'Invalid channel(s)';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
handleSubmit: (values, { props }) => {
|
||||
const { connect, select, join } = props;
|
||||
const channels = values.channels.split(',');
|
||||
const channels = values.channels ? values.channels.split(',') : [];
|
||||
delete values.channels;
|
||||
|
||||
values.port = `${values.port}`;
|
||||
@ -184,7 +236,12 @@ export default withFormik({
|
||||
select(values.host);
|
||||
|
||||
if (channels.length > 0) {
|
||||
join(channels, values.host);
|
||||
join(channels, values.host, false);
|
||||
}
|
||||
|
||||
localStorage.lastNick = values.nick;
|
||||
if (values.realname) {
|
||||
localStorage.lastRealname = values.realname;
|
||||
}
|
||||
}
|
||||
})(Connect);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Navicon from 'containers/Navicon';
|
||||
import Navicon from 'components/ui/Navicon';
|
||||
import Button from 'components/ui/Button';
|
||||
import Checkbox from 'components/ui/Checkbox';
|
||||
import FileInput from 'components/ui/FileInput';
|
||||
@ -7,6 +7,7 @@ import FileInput from 'components/ui/FileInput';
|
||||
const Settings = ({
|
||||
settings,
|
||||
installable,
|
||||
version,
|
||||
setSetting,
|
||||
onCertChange,
|
||||
onKeyChange,
|
||||
@ -16,14 +17,11 @@ const Settings = ({
|
||||
const status = settings.uploadingCert ? 'Uploading...' : 'Upload';
|
||||
const error = settings.certError;
|
||||
|
||||
const handleInstallClick = useCallback(
|
||||
async () => {
|
||||
installable.prompt();
|
||||
await installable.userChoice;
|
||||
onInstall();
|
||||
},
|
||||
[installable]
|
||||
);
|
||||
const handleInstallClick = useCallback(async () => {
|
||||
installable.prompt();
|
||||
await installable.userChoice;
|
||||
onInstall();
|
||||
}, [installable]);
|
||||
|
||||
return (
|
||||
<div className="settings-container">
|
||||
@ -31,7 +29,10 @@ const Settings = ({
|
||||
<Navicon />
|
||||
<h1>Settings</h1>
|
||||
{installable && (
|
||||
<Button className="button-install" onClick={handleInstallClick}>
|
||||
<Button
|
||||
className="settings-button button-install"
|
||||
onClick={handleInstallClick}
|
||||
>
|
||||
<h2>Install</h2>
|
||||
</Button>
|
||||
)}
|
||||
@ -40,7 +41,7 @@ const Settings = ({
|
||||
<Checkbox
|
||||
name="coloredNicks"
|
||||
label="Colored nicks"
|
||||
checked={settings.coloredNicks}
|
||||
checked={!!settings.coloredNicks}
|
||||
onChange={e => setSetting('coloredNicks', e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
@ -71,6 +72,13 @@ const Settings = ({
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{version && (
|
||||
<div className="settings-version">
|
||||
<p>{version.tag}</p>
|
||||
<p>Commit: {version.commit}</p>
|
||||
<p>Build Date: {version.date}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,7 +1,19 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
|
||||
const Button = ({ children, ...props }) => (
|
||||
<button type="button" {...props}>
|
||||
const Button = ({ children, category, className, icon: Icon, ...props }) => (
|
||||
<button
|
||||
className={cn(
|
||||
{
|
||||
[`button-${category}`]: category,
|
||||
'icon-button': Icon && !children
|
||||
},
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{Icon && <Icon />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const Checkbox = ({ name, label, topLabel, ...props }) => (
|
||||
const Checkbox = ({ name, label, topLabel, classNameLabel, ...props }) => (
|
||||
<label
|
||||
className={classnames('checkbox', {
|
||||
className={classnames('checkbox', classNameLabel, {
|
||||
'top-label': topLabel
|
||||
})}
|
||||
htmlFor={name}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { PureComponent, createRef } from 'react';
|
||||
import cn from 'classnames';
|
||||
import { stringWidth } from 'utils';
|
||||
|
||||
export default class Editable extends PureComponent {
|
||||
@ -17,11 +18,7 @@ export default class Editable extends PureComponent {
|
||||
// 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) {
|
||||
} else if (this.state.editing && prevProps.value !== this.props.value) {
|
||||
this.updateInputWidth(this.props.value);
|
||||
}
|
||||
}
|
||||
@ -79,7 +76,7 @@ export default class Editable extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, className, value } = this.props;
|
||||
const { children, className, editable, value } = this.props;
|
||||
|
||||
const style = {
|
||||
width: this.state.width,
|
||||
@ -90,7 +87,7 @@ export default class Editable extends PureComponent {
|
||||
return this.state.editing ? (
|
||||
<input
|
||||
ref={this.inputEl}
|
||||
className={className}
|
||||
className={`editable-wrap ${className}`}
|
||||
type="text"
|
||||
value={value}
|
||||
onBlur={this.handleBlur}
|
||||
@ -101,7 +98,14 @@ export default class Editable extends PureComponent {
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div onClick={this.startEditing}>{children}</div>
|
||||
<div
|
||||
className={cn('editable-wrap', {
|
||||
'editable-wrap-editable': editable
|
||||
})}
|
||||
onClick={this.startEditing}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,19 @@
|
||||
import React from 'react';
|
||||
import { FiMenu } from 'react-icons/fi';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'components/ui/Button';
|
||||
import { toggleMenu } from 'state/ui';
|
||||
|
||||
const Navicon = ({ onClick }) => (
|
||||
<i className="icon-menu navicon" onClick={onClick} />
|
||||
);
|
||||
const Navicon = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="navicon"
|
||||
icon={FiMenu}
|
||||
onClick={() => dispatch(toggleMenu())}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navicon;
|
||||
|
@ -38,49 +38,90 @@ export default class TextInput extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, label = capitalize(name), noError, ...props } = this.props;
|
||||
const {
|
||||
name,
|
||||
label = capitalize(name),
|
||||
noError,
|
||||
noTrim,
|
||||
transform,
|
||||
blurTransform,
|
||||
...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} />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
render={({ field, form }) => (
|
||||
<>
|
||||
<div className="textinput">
|
||||
<input
|
||||
className={field.value && 'value'}
|
||||
type="text"
|
||||
name={name}
|
||||
id={name}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
ref={this.input}
|
||||
onFocus={this.handleFocus}
|
||||
{...field}
|
||||
{...props}
|
||||
onChange={e => {
|
||||
let v = e.target.value;
|
||||
|
||||
if (!noTrim) {
|
||||
v = v.trim();
|
||||
}
|
||||
|
||||
if (transform) {
|
||||
v = transform(v);
|
||||
}
|
||||
|
||||
if (v !== field.value) {
|
||||
form.setFieldValue(name, v);
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(e);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBlur={e => {
|
||||
field.onBlur(e);
|
||||
if (props.onBlur) {
|
||||
props.onBlur(e);
|
||||
}
|
||||
|
||||
if (blurTransform) {
|
||||
const v = blurTransform(e.target.value);
|
||||
|
||||
if (v && v !== field.value) {
|
||||
form.setFieldValue(name, v);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={classnames('textinput-label', 'textinput-1', {
|
||||
value: field.value,
|
||||
error: form.touched[name] && form.errors[name]
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<span
|
||||
className={classnames('textinput-label', 'textinput-2', {
|
||||
value: field.value,
|
||||
error: form.touched[name] && form.errors[name]
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{!noError && <Error name={name} />}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -5,22 +5,19 @@ 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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
name={name}
|
||||
checked={field.value}
|
||||
onChange={e => {
|
||||
field.onChange(e);
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -2,8 +2,9 @@ import { createStructuredSelector } from 'reselect';
|
||||
import App from 'components/App';
|
||||
import { getConnected } from 'state/app';
|
||||
import { getSortedChannels } from 'state/channels';
|
||||
import { openModal, getHasOpenModals } from 'state/modals';
|
||||
import { getPrivateChats } from 'state/privateChats';
|
||||
import { getServers } from 'state/servers';
|
||||
import { getNetworks } from 'state/networks';
|
||||
import { getSelectedTab, select } from 'state/tab';
|
||||
import { getShowTabList, hideMenu } from 'state/ui';
|
||||
import connect from 'utils/connect';
|
||||
@ -13,15 +14,13 @@ const mapState = createStructuredSelector({
|
||||
channels: getSortedChannels,
|
||||
connected: getConnected,
|
||||
privateChats: getPrivateChats,
|
||||
servers: getServers,
|
||||
networks: getNetworks,
|
||||
showTabList: getShowTabList,
|
||||
tab: getSelectedTab,
|
||||
newVersionAvailable: state => state.app.newVersionAvailable
|
||||
newVersionAvailable: state => state.app.newVersionAvailable,
|
||||
hasOpenModals: getHasOpenModals
|
||||
});
|
||||
|
||||
const mapDispatch = { push, select, hideMenu };
|
||||
const mapDispatch = { push, select, hideMenu, openModal };
|
||||
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(App);
|
||||
export default connect(mapState, mapDispatch)(App);
|
||||
|
@ -22,15 +22,16 @@ import {
|
||||
fetchMessages,
|
||||
addFetchedMessages
|
||||
} from 'state/messages';
|
||||
import { openModal } from 'state/modals';
|
||||
import { openPrivateChat, closePrivateChat } from 'state/privateChats';
|
||||
import { getSearch, searchMessages, toggleSearch } from 'state/search';
|
||||
import {
|
||||
getCurrentNick,
|
||||
getCurrentServerStatus,
|
||||
getCurrentNetworkError,
|
||||
disconnect,
|
||||
setNick,
|
||||
setServerName
|
||||
} from 'state/servers';
|
||||
setNetworkName
|
||||
} from 'state/networks';
|
||||
import { getSettings } from 'state/settings';
|
||||
import { getSelectedTab, select } from 'state/tab';
|
||||
import { getShowUserList, toggleUserList } from 'state/ui';
|
||||
@ -44,7 +45,7 @@ const mapState = createStructuredSelector({
|
||||
nick: getCurrentNick,
|
||||
search: getSearch,
|
||||
showUserList: getShowUserList,
|
||||
status: getCurrentServerStatus,
|
||||
error: getCurrentNetworkError,
|
||||
tab: getSelectedTab,
|
||||
title: getSelectedTabTitle,
|
||||
users: getSelectedChannelUsers,
|
||||
@ -58,6 +59,7 @@ const mapDispatch = dispatch => ({
|
||||
closePrivateChat,
|
||||
disconnect,
|
||||
fetchMessages,
|
||||
openModal,
|
||||
openPrivateChat,
|
||||
part,
|
||||
runCommand,
|
||||
@ -65,7 +67,7 @@ const mapDispatch = dispatch => ({
|
||||
select,
|
||||
sendMessage,
|
||||
setNick,
|
||||
setServerName,
|
||||
setNetworkName,
|
||||
toggleSearch,
|
||||
toggleUserList
|
||||
},
|
||||
@ -83,7 +85,4 @@ const mapDispatch = dispatch => ({
|
||||
)
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(Chat);
|
||||
export default connect(mapState, mapDispatch)(Chat);
|
||||
|
@ -2,22 +2,20 @@ import { createStructuredSelector } from 'reselect';
|
||||
import Connect from 'components/pages/Connect';
|
||||
import { getConnectDefaults, getApp } from 'state/app';
|
||||
import { join } from 'state/channels';
|
||||
import { connect as connectServer } from 'state/servers';
|
||||
import { connect as connectNetwork } from 'state/networks';
|
||||
import { select } from 'state/tab';
|
||||
import connect from 'utils/connect';
|
||||
|
||||
const mapState = createStructuredSelector({
|
||||
defaults: getConnectDefaults,
|
||||
hexIP: state => getApp(state).hexIP
|
||||
hexIP: state => getApp(state).hexIP,
|
||||
query: state => state.router.query
|
||||
});
|
||||
|
||||
const mapDispatch = {
|
||||
join,
|
||||
connect: connectServer,
|
||||
connect: connectNetwork,
|
||||
select
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(Connect);
|
||||
export default connect(mapState, mapDispatch)(Connect);
|
||||
|
@ -1,12 +0,0 @@
|
||||
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);
|
@ -12,7 +12,8 @@ import connect from 'utils/connect';
|
||||
|
||||
const mapState = createStructuredSelector({
|
||||
settings: getSettings,
|
||||
installable: state => state.app.installable
|
||||
installable: state => state.app.installable,
|
||||
version: state => state.app.version
|
||||
});
|
||||
|
||||
const mapDispatch = {
|
||||
@ -23,7 +24,4 @@ const mapDispatch = {
|
||||
onInstall: () => appSet('installable', null)
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapState,
|
||||
mapDispatch
|
||||
)(Settings);
|
||||
export default connect(mapState, mapDispatch)(Settings);
|
||||
|
17
client/js/containers/TabListItem.js
Normal file
17
client/js/containers/TabListItem.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { createStructuredSelector } from 'reselect';
|
||||
import get from 'lodash/get';
|
||||
import TabListItem from 'components/TabListItem';
|
||||
import connect from 'utils/connect';
|
||||
|
||||
const mapState = createStructuredSelector({
|
||||
error: (state, { network, target }) => {
|
||||
const messages = get(state, ['messages', network, target]);
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
return messages[messages.length - 1].type === 'error';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(mapState)(TabListItem);
|
8
client/js/hot.js
Normal file
8
client/js/hot.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { setConfig } from 'react-hot-loader';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
setConfig({
|
||||
ignoreSFC: !!ReactDOM.setHotElementComparator,
|
||||
pureSFC: true,
|
||||
pureRender: true
|
||||
});
|
@ -1,5 +1,6 @@
|
||||
import './hot';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom';
|
||||
import { render } from 'react-dom';
|
||||
|
||||
import Root from 'components/Root';
|
||||
import { appSet } from 'state/app';
|
||||
@ -10,7 +11,6 @@ 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';
|
||||
@ -23,7 +23,7 @@ const store = configureStore(socket);
|
||||
initRouter(routes, store);
|
||||
runModules({ store, socket });
|
||||
|
||||
createRoot(document.getElementById('root')).render(<Root store={store} />);
|
||||
render(<Root store={store} />, document.getElementById('root'));
|
||||
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
e.preventDefault();
|
||||
|
@ -4,22 +4,27 @@ import { isChannel } from 'utils';
|
||||
export const beforeHandler = '_before';
|
||||
export const notFoundHandler = 'commandNotFound';
|
||||
|
||||
function createContext({ dispatch, getState }, { server, channel }) {
|
||||
return { dispatch, getState, server, channel, isChannel: isChannel(channel) };
|
||||
function createContext({ dispatch, getState }, { network, channel }) {
|
||||
return {
|
||||
dispatch,
|
||||
getState,
|
||||
network,
|
||||
channel,
|
||||
inChannel: isChannel(channel)
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Pull this out as convenience action
|
||||
function process({ dispatch, server, channel }, result) {
|
||||
function process({ dispatch, network, channel }, result) {
|
||||
if (typeof result === 'string') {
|
||||
dispatch(inform(result, server, channel));
|
||||
dispatch(inform(result, network, channel));
|
||||
} else if (Array.isArray(result)) {
|
||||
if (typeof result[0] === 'string') {
|
||||
dispatch(inform(result, server, channel));
|
||||
dispatch(inform(result, network, channel));
|
||||
} else if (typeof result[0] === 'object') {
|
||||
dispatch(addMessages(result, server, channel));
|
||||
dispatch(addMessages(result, network, channel));
|
||||
}
|
||||
} else if (typeof result === 'object' && result) {
|
||||
dispatch(print(result.content, server, channel, result.type));
|
||||
dispatch(print(result.content, network, channel, result.type));
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +32,7 @@ export default function createCommandMiddleware(type, handlers) {
|
||||
return store => next => action => {
|
||||
if (action.type === type) {
|
||||
const words = action.command.slice(1).split(' ');
|
||||
const command = words[0];
|
||||
const command = words[0].toLowerCase();
|
||||
const params = words.slice(1);
|
||||
|
||||
if (command in handlers) {
|
||||
|
@ -10,7 +10,7 @@ const message = store => next => {
|
||||
|
||||
return action => {
|
||||
if (action.type === ADD_MESSAGES && action.prepend) {
|
||||
const key = `${action.server} ${action.channel}`;
|
||||
const key = `${action.network} ${action.channel}`;
|
||||
|
||||
if (ready[key]) {
|
||||
ready[key] = false;
|
||||
@ -19,7 +19,7 @@ const message = store => next => {
|
||||
|
||||
cache[key] = action;
|
||||
} else if (action.type === ADD_FETCHED_MESSAGES) {
|
||||
const key = `${action.server} ${action.channel}`;
|
||||
const key = `${action.network} ${action.channel}`;
|
||||
ready[key] = true;
|
||||
|
||||
if (cache[key]) {
|
||||
|
@ -1,23 +1,27 @@
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { getRouter } from 'state';
|
||||
import { getCurrentServerName } from 'state/servers';
|
||||
import { getCurrentNetworkName } from 'state/networks';
|
||||
import { observe } from 'utils/observe';
|
||||
|
||||
export default function documentTitle({ store }) {
|
||||
observe(store, [getRouter, getCurrentServerName], (router, serverName) => {
|
||||
observe(store, [getRouter, getCurrentNetworkName], (router, networkName) => {
|
||||
let title;
|
||||
|
||||
if (router.route === 'chat') {
|
||||
const { name } = router.params;
|
||||
const { network, name } = router.params;
|
||||
if (name) {
|
||||
title = `${name} @ ${serverName}`;
|
||||
title = `${name} @ ${networkName || network}`;
|
||||
} else {
|
||||
title = serverName;
|
||||
title = networkName || network;
|
||||
}
|
||||
} else {
|
||||
title = capitalize(router.route);
|
||||
}
|
||||
|
||||
document.title = `${title} | Dispatch`;
|
||||
if (title) {
|
||||
document.title = `${title} | Dispatch`;
|
||||
} else {
|
||||
document.title = 'Dispatch';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -1,22 +1,15 @@
|
||||
import FontFaceObserver from 'fontfaceobserver';
|
||||
import { setCharWidth } from 'state/app';
|
||||
import { stringWidth } from 'utils';
|
||||
|
||||
export default function fonts({ store }) {
|
||||
export default async function fonts({ store }) {
|
||||
let { charWidth } = localStorage;
|
||||
if (charWidth) {
|
||||
store.dispatch(setCharWidth(parseFloat(charWidth)));
|
||||
} else {
|
||||
await document.fonts.load('16px Roboto Mono');
|
||||
|
||||
charWidth = stringWidth(' ', '16px Roboto Mono');
|
||||
store.dispatch(setCharWidth(charWidth));
|
||||
localStorage.charWidth = charWidth;
|
||||
}
|
||||
|
||||
new FontFaceObserver('Roboto Mono').load().then(() => {
|
||||
if (!charWidth) {
|
||||
charWidth = stringWidth(' ', '16px Roboto Mono');
|
||||
store.dispatch(setCharWidth(charWidth));
|
||||
localStorage.charWidth = charWidth;
|
||||
}
|
||||
});
|
||||
|
||||
new FontFaceObserver('Montserrat').load();
|
||||
new FontFaceObserver('Montserrat', { weight: 700 }).load();
|
||||
new FontFaceObserver('Roboto Mono', { weight: 700 }).load();
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import documentTitle from './documentTitle';
|
||||
import fonts from './fonts';
|
||||
import initialState from './initialState';
|
||||
import route from './route';
|
||||
import socket from './socket';
|
||||
import storage from './storage';
|
||||
import widthUpdates from './widthUpdates';
|
||||
@ -8,6 +9,7 @@ import widthUpdates from './widthUpdates';
|
||||
export default function runModules(ctx) {
|
||||
fonts(ctx);
|
||||
initialState(ctx);
|
||||
route(ctx);
|
||||
|
||||
documentTitle(ctx);
|
||||
socket(ctx);
|
||||
|
@ -1,75 +1,45 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
import Cookie from 'js-cookie';
|
||||
import { socket as socketActions } from 'state/actions';
|
||||
import { getWrapWidth, setConnectDefaults, appSet } from 'state/app';
|
||||
import { INIT } from 'state/actions';
|
||||
import { getConnected, getWrapWidth } from 'state/app';
|
||||
import { searchChannels } from 'state/channelSearch';
|
||||
import { addMessages } from 'state/messages';
|
||||
import { setSettings } from 'state/settings';
|
||||
import { select, updateSelection } from 'state/tab';
|
||||
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({
|
||||
type: socketActions.SERVERS,
|
||||
data: env.servers
|
||||
});
|
||||
|
||||
if (!store.getState().router.route) {
|
||||
const tab = Cookie.get('tab');
|
||||
if (tab) {
|
||||
const [server, name = null] = tab.split(/;(.+)/);
|
||||
|
||||
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));
|
||||
} else {
|
||||
store.dispatch(updateSelection());
|
||||
}
|
||||
} else {
|
||||
store.dispatch(updateSelection());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.dispatch(replace('/connect'));
|
||||
}
|
||||
|
||||
if (env.channels) {
|
||||
store.dispatch({
|
||||
type: socketActions.CHANNELS,
|
||||
data: env.channels
|
||||
});
|
||||
}
|
||||
|
||||
if (env.users) {
|
||||
store.dispatch({
|
||||
type: socketActions.USERS,
|
||||
...env.users
|
||||
});
|
||||
}
|
||||
|
||||
// Wait until wrapWidth gets initialized so that height calculations
|
||||
// only happen once for these messages
|
||||
when(store, getWrapWidth, () => {
|
||||
if (env.messages) {
|
||||
const { messages, server, to, next } = env.messages;
|
||||
store.dispatch(addMessages(messages, server, to, false, next));
|
||||
store.dispatch({
|
||||
type: INIT,
|
||||
settings: env.settings,
|
||||
networks: env.networks,
|
||||
channels: env.channels,
|
||||
openDMs: env.openDMs,
|
||||
users: env.users,
|
||||
app: {
|
||||
connectDefaults: env.defaults,
|
||||
initialized: true,
|
||||
hexIP: env.hexIP,
|
||||
version: env.version
|
||||
}
|
||||
});
|
||||
|
||||
if (env.messages) {
|
||||
// Wait until wrapWidth gets initialized so that height calculations
|
||||
// only happen once for these messages
|
||||
when(store, getWrapWidth, () => {
|
||||
const { messages, network, to, next } = env.messages;
|
||||
store.dispatch(addMessages(messages, network, to, false, next));
|
||||
});
|
||||
}
|
||||
|
||||
if (env.networks) {
|
||||
when(store, getConnected, () =>
|
||||
// Cache top channels for each network
|
||||
env.networks.forEach(({ host }) =>
|
||||
store.dispatch(searchChannels(host, ''))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
export default async function initialState(ctx) {
|
||||
const env = await window.__init__;
|
||||
ctx.socket.connect();
|
||||
|
22
client/js/modules/route.js
Normal file
22
client/js/modules/route.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { updateSelection } from 'state/tab';
|
||||
import { observe, when } from 'utils/observe';
|
||||
|
||||
export default function route({ store }) {
|
||||
let first = true;
|
||||
|
||||
when(
|
||||
store,
|
||||
state => state.app.initialized,
|
||||
() =>
|
||||
observe(
|
||||
store,
|
||||
state => state.router,
|
||||
router => {
|
||||
if (!router.route || router.route === 'chat') {
|
||||
store.dispatch(updateSelection(first));
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
@ -1,25 +1,22 @@
|
||||
import { socketAction } from 'state/actions';
|
||||
import { setConnected } from 'state/app';
|
||||
import { kicked } from 'state/channels';
|
||||
import {
|
||||
broadcast,
|
||||
inform,
|
||||
print,
|
||||
addMessage,
|
||||
addMessages
|
||||
addMessages,
|
||||
addEvent,
|
||||
broadcastEvent
|
||||
} from 'state/messages';
|
||||
import { reconnect } from 'state/servers';
|
||||
import { openModal } from 'state/modals';
|
||||
import { reconnect } from 'state/networks';
|
||||
import { select } from 'state/tab';
|
||||
import { find, normalizeChannel } from 'utils';
|
||||
import { find } from 'utils';
|
||||
|
||||
function withReason(message, reason) {
|
||||
return message + (reason ? ` (${reason})` : '');
|
||||
}
|
||||
|
||||
function findChannels(state, server, user) {
|
||||
function findChannels(state, network, user) {
|
||||
const channels = [];
|
||||
|
||||
Object.keys(state.channels[server]).forEach(channel => {
|
||||
if (find(state.channels[server][channel].users, u => u.nick === user)) {
|
||||
Object.keys(state.channels[network]).forEach(channel => {
|
||||
if (find(state.channels[network][channel].users, u => u.nick === user)) {
|
||||
channels.push(channel);
|
||||
}
|
||||
});
|
||||
@ -33,67 +30,59 @@ export default function handleSocket({
|
||||
}) {
|
||||
const handlers = {
|
||||
message(message) {
|
||||
dispatch(addMessage(message, message.server, message.to));
|
||||
dispatch(addMessage(message, message.network, message.to));
|
||||
return false;
|
||||
},
|
||||
|
||||
pm(message) {
|
||||
dispatch(addMessage(message, message.server, message.from));
|
||||
dispatch(addMessage(message, message.network, message.from));
|
||||
return false;
|
||||
},
|
||||
|
||||
messages({ messages, server, to, prepend, next }) {
|
||||
dispatch(addMessages(messages, server, to, prepend, next));
|
||||
messages({ messages, network, to, prepend, next }) {
|
||||
dispatch(addMessages(messages, network, to, prepend, next));
|
||||
return false;
|
||||
},
|
||||
|
||||
join({ user, server, channels }) {
|
||||
const state = getState();
|
||||
const tab = state.tab.selected;
|
||||
const [joinedChannel] = channels;
|
||||
if (tab.server && tab.name) {
|
||||
const { nick } = state.servers[tab.server];
|
||||
if (
|
||||
tab.server === server &&
|
||||
nick === user &&
|
||||
tab.name !== joinedChannel &&
|
||||
normalizeChannel(tab.name) === normalizeChannel(joinedChannel)
|
||||
) {
|
||||
dispatch(select(server, joinedChannel));
|
||||
}
|
||||
join({ user, network, channels }) {
|
||||
dispatch(addEvent(network, channels[0], 'join', user));
|
||||
},
|
||||
|
||||
part({ user, network, channel, reason }) {
|
||||
dispatch(addEvent(network, channel, 'part', user, reason));
|
||||
},
|
||||
|
||||
quit({ user, network, reason }) {
|
||||
const channels = findChannels(getState(), network, user);
|
||||
dispatch(broadcastEvent(network, channels, 'quit', user, reason));
|
||||
},
|
||||
|
||||
kick({ network, channel, sender, user, reason }) {
|
||||
dispatch(kicked(network, channel, user));
|
||||
dispatch(addEvent(network, channel, 'kick', user, sender, reason));
|
||||
},
|
||||
|
||||
nick({ network, oldNick, newNick }) {
|
||||
if (oldNick) {
|
||||
const channels = findChannels(getState(), network, oldNick);
|
||||
dispatch(broadcastEvent(network, channels, 'nick', oldNick, newNick));
|
||||
}
|
||||
|
||||
dispatch(inform(`${user} joined the channel`, server, joinedChannel));
|
||||
},
|
||||
|
||||
part({ user, server, channel, reason }) {
|
||||
dispatch(
|
||||
inform(withReason(`${user} left the channel`, reason), server, channel)
|
||||
);
|
||||
},
|
||||
|
||||
quit({ user, server, reason }) {
|
||||
const channels = findChannels(getState(), server, user);
|
||||
dispatch(broadcast(withReason(`${user} quit`, reason), server, channels));
|
||||
},
|
||||
|
||||
nick({ server, oldNick, newNick }) {
|
||||
const channels = findChannels(getState(), server, oldNick);
|
||||
dispatch(
|
||||
broadcast(`${oldNick} changed nick to ${newNick}`, server, channels)
|
||||
);
|
||||
},
|
||||
|
||||
topic({ server, channel, topic, nick }) {
|
||||
topic({ network, channel, topic, nick }) {
|
||||
if (nick) {
|
||||
if (topic) {
|
||||
dispatch(inform(`${nick} changed the topic to:`, server, channel));
|
||||
dispatch(print(topic, server, channel));
|
||||
} else {
|
||||
dispatch(inform(`${nick} cleared the topic`, server, channel));
|
||||
}
|
||||
dispatch(addEvent(network, channel, 'topic', nick, topic));
|
||||
}
|
||||
},
|
||||
|
||||
motd({ content, server }) {
|
||||
dispatch(addMessages(content.map(line => ({ content: line })), server));
|
||||
motd({ content, network }) {
|
||||
dispatch(
|
||||
addMessages(
|
||||
content.map(line => ({ content: line })),
|
||||
network
|
||||
)
|
||||
);
|
||||
return false;
|
||||
},
|
||||
|
||||
whois(data) {
|
||||
@ -109,34 +98,82 @@ export default function handleSocket({
|
||||
`Server: ${data.server}`,
|
||||
`Channels: ${data.channels}`
|
||||
],
|
||||
tab.server,
|
||||
tab.network,
|
||||
tab.name
|
||||
)
|
||||
);
|
||||
return false;
|
||||
},
|
||||
|
||||
print(message) {
|
||||
const tab = getState().tab.selected;
|
||||
dispatch(addMessage(message, tab.server, tab.name));
|
||||
dispatch(addMessage(message, tab.network, tab.name));
|
||||
return false;
|
||||
},
|
||||
|
||||
connection_update({ server, errorType }) {
|
||||
if (
|
||||
errorType === 'verify' &&
|
||||
window.confirm(
|
||||
'The server is using a self-signed certificate, continue anyway?'
|
||||
)
|
||||
) {
|
||||
error({ network, target, message }) {
|
||||
const state = getState();
|
||||
const tab = state.tab.selected;
|
||||
|
||||
if (network === tab.network) {
|
||||
// Print it in the current channel if the error happened on
|
||||
// the current network
|
||||
target = tab.name;
|
||||
} else if (!state.channels[network]?.[target]) {
|
||||
// Print it the network tab if the target does not exist
|
||||
target = null;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
addMessage({ content: message, type: 'error' }, network, target)
|
||||
);
|
||||
return false;
|
||||
},
|
||||
|
||||
connection_update({ network, errorType }) {
|
||||
if (errorType === 'verify') {
|
||||
dispatch(
|
||||
reconnect(server, {
|
||||
skipVerify: true
|
||||
openModal('confirm', {
|
||||
question:
|
||||
'The network is using a self-signed certificate, continue anyway?',
|
||||
onConfirm: () =>
|
||||
dispatch(
|
||||
reconnect(network, {
|
||||
skipVerify: true
|
||||
})
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
_connected(connected) {
|
||||
dispatch(setConnected(connected));
|
||||
dcc_send({ network, from, filename, size, url }) {
|
||||
const networkName = getState().networks[network]?.name || network;
|
||||
|
||||
dispatch(
|
||||
openModal('confirm', {
|
||||
question: `${from} on ${networkName} is sending you (${size}): ${filename}`,
|
||||
confirmation: 'Download',
|
||||
onConfirm: () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.click();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const afterHandlers = {
|
||||
channel_forward(forward) {
|
||||
const { selected } = getState().tab;
|
||||
|
||||
if (
|
||||
selected.network === forward.network &&
|
||||
selected.name === forward.old
|
||||
) {
|
||||
dispatch(select(forward.network, forward.new, true));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -148,14 +185,12 @@ export default function handleSocket({
|
||||
action = { ...data, type: socketAction(type) };
|
||||
}
|
||||
|
||||
if (type in handlers) {
|
||||
handlers[type](data);
|
||||
}
|
||||
|
||||
if (type.charAt(0) === '_') {
|
||||
if (handlers[type]?.(data) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(action);
|
||||
|
||||
afterHandlers[type]?.(data);
|
||||
});
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import Cookie from 'js-cookie';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { getSelectedTab } from 'state/tab';
|
||||
import { isChannel, stringifyTab } from 'utils';
|
||||
import { stringifyTab } from 'utils';
|
||||
import { observe } from 'utils/observe';
|
||||
|
||||
const saveTab = debounce(
|
||||
@ -11,7 +11,7 @@ const saveTab = debounce(
|
||||
|
||||
export default function storage({ store }) {
|
||||
observe(store, getSelectedTab, tab => {
|
||||
if (isChannel(tab) || (tab.server && !tab.name)) {
|
||||
if (tab.network) {
|
||||
saveTab(tab);
|
||||
}
|
||||
});
|
||||
|
@ -10,7 +10,6 @@ const smallScreen = 600;
|
||||
|
||||
export default function widthUpdates({ store }) {
|
||||
when(store, getCharWidth, charWidth => {
|
||||
window.messageIndent = 6 * charWidth;
|
||||
const scrollBarWidth = measureScrollBarWidth();
|
||||
let prevWrapWidth;
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default {
|
||||
connect: '/connect',
|
||||
settings: '/settings',
|
||||
chat: '/:server(/:name)'
|
||||
chat: '/:network(/:name)'
|
||||
};
|
||||
|
@ -23,10 +23,8 @@ function registerValidSW(swUrl, config) {
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
} else if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
20
client/js/state/__tests__/actions-networks.test.js
Normal file
20
client/js/state/__tests__/actions-networks.test.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { connect, setNetworkName } from '../networks';
|
||||
|
||||
describe('setNetworkName()', () => {
|
||||
it('passes valid names to the network', () => {
|
||||
const name = 'cake';
|
||||
const network = 'srv';
|
||||
|
||||
expect(setNetworkName(name, network)).toMatchObject({
|
||||
socket: {
|
||||
type: 'set_network_name',
|
||||
data: { name, network }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not pass invalid names to the network', () => {
|
||||
expect(setNetworkName('', 'srv').socket).toBeUndefined();
|
||||
expect(setNetworkName(' ', 'srv').socket).toBeUndefined();
|
||||
});
|
||||
});
|
@ -1,20 +0,0 @@
|
||||
import { connect, setServerName } from '../servers';
|
||||
|
||||
describe('setServerName()', () => {
|
||||
it('passes valid names to the server', () => {
|
||||
const name = 'cake';
|
||||
const server = 'srv';
|
||||
|
||||
expect(setServerName(name, server)).toMatchObject({
|
||||
socket: {
|
||||
type: 'set_server_name',
|
||||
data: { name, server }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not pass invalid names to the server', () => {
|
||||
expect(setServerName('', 'srv').socket).toBeUndefined();
|
||||
expect(setServerName(' ', 'srv').socket).toBeUndefined();
|
||||
});
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import reducer, { compareUsers, getSortedChannels } from '../channels';
|
||||
import { connect } from '../servers';
|
||||
import { connect } from '../networks';
|
||||
import * as actions from '../actions';
|
||||
|
||||
describe('channel reducer', () => {
|
||||
@ -17,7 +17,7 @@ describe('channel reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.PART,
|
||||
server: 'srv1',
|
||||
network: 'srv1',
|
||||
channels: ['chan1', 'chan3']
|
||||
});
|
||||
|
||||
@ -38,7 +38,7 @@ describe('channel reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.socket.PART,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
channel: 'chan1',
|
||||
user: 'nick2'
|
||||
});
|
||||
@ -46,9 +46,13 @@ describe('channel reducer', () => {
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
name: 'chan1',
|
||||
joined: true,
|
||||
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
|
||||
},
|
||||
chan2: {
|
||||
name: 'chan2',
|
||||
joined: true,
|
||||
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
|
||||
}
|
||||
}
|
||||
@ -61,6 +65,8 @@ describe('channel reducer', () => {
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
name: 'chan1',
|
||||
joined: true,
|
||||
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
|
||||
}
|
||||
}
|
||||
@ -74,16 +80,81 @@ describe('channel reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.socket.QUIT,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
user: 'nick2'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
name: 'chan1',
|
||||
joined: true,
|
||||
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
|
||||
},
|
||||
chan2: {
|
||||
name: 'chan2',
|
||||
joined: true,
|
||||
users: []
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('handles KICKED', () => {
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({
|
||||
host: 'srv',
|
||||
nick: 'nick2'
|
||||
})
|
||||
);
|
||||
state = reducer(state, socket_join('srv', 'chan1', 'nick1'));
|
||||
state = reducer(state, socket_join('srv', 'chan1', 'nick2'));
|
||||
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.KICKED,
|
||||
network: 'srv',
|
||||
channel: 'chan2',
|
||||
user: 'nick2',
|
||||
self: true
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
name: 'chan1',
|
||||
joined: true,
|
||||
users: [
|
||||
{ mode: '', nick: 'nick1', renderName: 'nick1' },
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' }
|
||||
]
|
||||
},
|
||||
chan2: {
|
||||
name: 'chan2',
|
||||
joined: false,
|
||||
users: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.KICKED,
|
||||
network: 'srv',
|
||||
channel: 'chan1',
|
||||
user: 'nick1'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
name: 'chan1',
|
||||
joined: true,
|
||||
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
|
||||
},
|
||||
chan2: {
|
||||
name: 'chan2',
|
||||
joined: false,
|
||||
users: []
|
||||
}
|
||||
}
|
||||
@ -97,7 +168,7 @@ describe('channel reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.socket.NICK,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
oldNick: 'nick1',
|
||||
newNick: 'nick3'
|
||||
});
|
||||
@ -105,12 +176,16 @@ describe('channel reducer', () => {
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
name: 'chan1',
|
||||
joined: true,
|
||||
users: [
|
||||
{ mode: '', nick: 'nick3', renderName: 'nick3' },
|
||||
{ mode: '', nick: 'nick2', renderName: 'nick2' }
|
||||
]
|
||||
},
|
||||
chan2: {
|
||||
name: 'chan2',
|
||||
joined: true,
|
||||
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
|
||||
}
|
||||
}
|
||||
@ -118,9 +193,10 @@ describe('channel reducer', () => {
|
||||
});
|
||||
|
||||
it('handles SOCKET_USERS', () => {
|
||||
const state = reducer(undefined, {
|
||||
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
|
||||
state = reducer(state, {
|
||||
type: actions.socket.USERS,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
channel: 'chan1',
|
||||
users: ['user3', 'user2', '@user4', 'user1', '+user5']
|
||||
});
|
||||
@ -128,6 +204,8 @@ describe('channel reducer', () => {
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: {
|
||||
name: 'chan1',
|
||||
joined: true,
|
||||
users: [
|
||||
{ mode: '', nick: 'user3', renderName: 'user3' },
|
||||
{ mode: '', nick: 'user2', renderName: 'user2' },
|
||||
@ -141,18 +219,18 @@ describe('channel reducer', () => {
|
||||
});
|
||||
|
||||
it('handles SOCKET_TOPIC', () => {
|
||||
const state = reducer(undefined, {
|
||||
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
|
||||
state = reducer(state, {
|
||||
type: actions.socket.TOPIC,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
channel: 'chan1',
|
||||
topic: 'the topic'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
chan1: {
|
||||
topic: 'the topic',
|
||||
users: []
|
||||
topic: 'the topic'
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -165,7 +243,7 @@ describe('channel reducer', () => {
|
||||
|
||||
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', ''));
|
||||
|
||||
expect(state).toEqual({
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
@ -183,7 +261,7 @@ describe('channel reducer', () => {
|
||||
state = reducer(state, socket_mode('srv', 'chan1', 'nick2', 'o', ''));
|
||||
state = reducer(state, socket_mode('srv', 'chan2', 'not_there', 'x', ''));
|
||||
|
||||
expect(state).toEqual({
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
chan1: {
|
||||
users: [
|
||||
@ -198,31 +276,31 @@ describe('channel reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('handles SOCKET_CHANNELS', () => {
|
||||
it('handles channels from INIT', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: actions.socket.CHANNELS,
|
||||
data: [
|
||||
{ server: 'srv', name: 'chan1', topic: 'the topic' },
|
||||
{ server: 'srv', name: 'chan2' },
|
||||
{ server: 'srv2', name: 'chan1' }
|
||||
type: actions.INIT,
|
||||
channels: [
|
||||
{ network: 'srv', name: 'chan1', topic: 'the topic' },
|
||||
{ network: 'srv', name: 'chan2', joined: true },
|
||||
{ network: 'srv2', name: 'chan1' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
chan1: { topic: 'the topic', users: [] },
|
||||
chan2: { users: [] }
|
||||
chan1: { name: 'chan1', topic: 'the topic', users: [] },
|
||||
chan2: { name: 'chan2', joined: true, users: [] }
|
||||
},
|
||||
srv2: {
|
||||
chan1: { users: [] }
|
||||
chan1: { name: 'chan1', users: [] }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('handles SOCKET_SERVERS', () => {
|
||||
it('handles networks from INIT', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: actions.socket.SERVERS,
|
||||
data: [{ host: '127.0.0.1' }, { host: 'thehost' }]
|
||||
type: actions.INIT,
|
||||
networks: [{ host: '127.0.0.1' }, { host: 'thehost' }]
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
@ -231,7 +309,7 @@ describe('channel reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('optimistically adds the server on CONNECT', () => {
|
||||
it('optimistically adds the network on CONNECT', () => {
|
||||
const state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
@ -242,7 +320,7 @@ describe('channel reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the server on DISCONNECT', () => {
|
||||
it('removes the network on DISCONNECT', () => {
|
||||
let state = {
|
||||
srv: {},
|
||||
srv2: {}
|
||||
@ -250,7 +328,7 @@ describe('channel reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv2'
|
||||
network: 'srv2'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
@ -259,19 +337,19 @@ describe('channel reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function socket_join(server, channel, user) {
|
||||
function socket_join(network, channel, user) {
|
||||
return {
|
||||
type: actions.socket.JOIN,
|
||||
server,
|
||||
network,
|
||||
user,
|
||||
channels: [channel]
|
||||
};
|
||||
}
|
||||
|
||||
function socket_mode(server, channel, user, add, remove) {
|
||||
function socket_mode(network, channel, user, add, remove) {
|
||||
return {
|
||||
type: actions.socket.MODE,
|
||||
server,
|
||||
network,
|
||||
channel,
|
||||
user,
|
||||
add,
|
||||
@ -306,25 +384,42 @@ describe('compareUsers()', () => {
|
||||
});
|
||||
|
||||
describe('getSortedChannels', () => {
|
||||
it('sorts servers and channels', () => {
|
||||
it('sorts networks and channels', () => {
|
||||
expect(
|
||||
getSortedChannels({
|
||||
channels: {
|
||||
'bob.com': {},
|
||||
'127.0.0.1': {
|
||||
'#chan1': {
|
||||
name: '#chan1',
|
||||
users: [],
|
||||
topic: 'cake'
|
||||
},
|
||||
'#pie': {},
|
||||
'##apples': {}
|
||||
'#pie': {
|
||||
name: '#pie'
|
||||
},
|
||||
'##apples': {
|
||||
name: '##apples'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
channels: ['##apples', '#chan1', '#pie']
|
||||
channels: [
|
||||
{
|
||||
name: '##apples'
|
||||
},
|
||||
{
|
||||
name: '#chan1',
|
||||
users: [],
|
||||
topic: 'cake'
|
||||
},
|
||||
{
|
||||
name: '#pie'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
address: 'bob.com',
|
||||
|
@ -1,12 +1,13 @@
|
||||
import reducer, { broadcast, getMessageTab } from '../messages';
|
||||
import * as actions from '../actions';
|
||||
import appReducer from '../app';
|
||||
import { unix } from 'utils';
|
||||
|
||||
describe('message reducer', () => {
|
||||
it('adds the message on ADD_MESSAGE', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: actions.ADD_MESSAGE,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
tab: '#chan1',
|
||||
message: {
|
||||
from: 'foo',
|
||||
@ -19,7 +20,7 @@ describe('message reducer', () => {
|
||||
'#chan1': [
|
||||
{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
content: [{ type: 'text', text: 'msg' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -29,7 +30,7 @@ describe('message reducer', () => {
|
||||
it('adds all the messages on ADD_MESSAGES', () => {
|
||||
const state = reducer(undefined, {
|
||||
type: actions.ADD_MESSAGES,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
tab: '#chan1',
|
||||
messages: [
|
||||
{
|
||||
@ -53,17 +54,17 @@ describe('message reducer', () => {
|
||||
'#chan1': [
|
||||
{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
content: [{ type: 'text', text: 'msg' }]
|
||||
},
|
||||
{
|
||||
from: 'bar',
|
||||
content: 'msg'
|
||||
content: [{ type: 'text', text: 'msg' }]
|
||||
}
|
||||
],
|
||||
'#chan2': [
|
||||
{
|
||||
from: 'foo',
|
||||
content: 'msg'
|
||||
content: [{ type: 'text', text: 'msg' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -79,10 +80,13 @@ describe('message reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.ADD_MESSAGES,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
tab: '#chan1',
|
||||
prepend: true,
|
||||
messages: [{ id: 1 }, { id: 2 }]
|
||||
messages: [
|
||||
{ id: 1, date: new Date() },
|
||||
{ id: 2, date: new Date() }
|
||||
]
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
@ -92,6 +96,90 @@ describe('message reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('adds date markers when prepending messages', () => {
|
||||
let state = {
|
||||
srv: {
|
||||
'#chan1': [{ id: 0, date: new Date(1990, 0, 3) }]
|
||||
}
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.ADD_MESSAGES,
|
||||
network: 'srv',
|
||||
tab: '#chan1',
|
||||
prepend: true,
|
||||
messages: [
|
||||
{ id: 1, time: unix(new Date(1990, 0, 1)) },
|
||||
{ id: 2, time: unix(new Date(1990, 0, 2)) }
|
||||
]
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
'#chan1': [
|
||||
{ id: 1 },
|
||||
{ type: 'date' },
|
||||
{ id: 2 },
|
||||
{ type: 'date' },
|
||||
{ id: 0 }
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a date marker when adding a message', () => {
|
||||
let state = {
|
||||
srv: {
|
||||
'#chan1': [{ id: 0, date: new Date(1999, 0, 1) }]
|
||||
}
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.ADD_MESSAGE,
|
||||
network: 'srv',
|
||||
tab: '#chan1',
|
||||
message: { id: 1, date: new Date(1990, 0, 2) }
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
'#chan1': [{ id: 0 }, { type: 'date' }, { id: 1 }]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds date markers when adding messages', () => {
|
||||
let state = {
|
||||
srv: {
|
||||
'#chan1': [{ id: 0, date: new Date(1990, 0, 1) }]
|
||||
}
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.ADD_MESSAGES,
|
||||
network: 'srv',
|
||||
tab: '#chan1',
|
||||
messages: [
|
||||
{ id: 1, time: unix(new Date(1990, 0, 2)) },
|
||||
{ id: 2, time: unix(new Date(1990, 0, 3)) },
|
||||
{ id: 3, time: unix(new Date(1990, 0, 3)) }
|
||||
]
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
srv: {
|
||||
'#chan1': [
|
||||
{ id: 0 },
|
||||
{ type: 'date' },
|
||||
{ id: 1 },
|
||||
{ type: 'date' },
|
||||
{ id: 2 },
|
||||
{ id: 3 }
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds messages to the correct tabs when broadcasting', () => {
|
||||
let state = {
|
||||
app: appReducer(undefined, { type: '' })
|
||||
@ -109,12 +197,16 @@ describe('message reducer', () => {
|
||||
|
||||
expect(messages.srv).not.toHaveProperty('srv');
|
||||
expect(messages.srv['#chan1']).toHaveLength(1);
|
||||
expect(messages.srv['#chan1'][0].content).toBe('test');
|
||||
expect(messages.srv['#chan1'][0].content).toMatchObject([
|
||||
{ type: 'text', text: 'test' }
|
||||
]);
|
||||
expect(messages.srv['#chan3']).toHaveLength(1);
|
||||
expect(messages.srv['#chan3'][0].content).toBe('test');
|
||||
expect(messages.srv['#chan3'][0].content).toMatchObject([
|
||||
{ type: 'text', text: 'test' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes all messages related to server when disconnecting', () => {
|
||||
it('deletes all messages related to network when disconnecting', () => {
|
||||
let state = {
|
||||
srv: {
|
||||
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
|
||||
@ -127,7 +219,7 @@ describe('message reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv'
|
||||
network: 'srv'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
@ -150,7 +242,7 @@ describe('message reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.PART,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
channels: ['#chan1']
|
||||
});
|
||||
|
||||
@ -163,6 +255,33 @@ describe('message reducer', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes direct messages when closing a direct message tab', () => {
|
||||
let state = {
|
||||
srv: {
|
||||
bob: [{ content: 'msg1' }, { content: 'msg2' }],
|
||||
'#chan2': [{ content: 'msg' }]
|
||||
},
|
||||
srv2: {
|
||||
'#chan1': [{ content: 'msg' }]
|
||||
}
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.CLOSE_PRIVATE_CHAT,
|
||||
network: 'srv',
|
||||
nick: 'bob'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
'#chan2': [{ content: 'msg' }]
|
||||
},
|
||||
srv2: {
|
||||
'#chan1': [{ content: 'msg' }]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessageTab()', () => {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import reducer, { connect, setServerName } from '../servers';
|
||||
import reducer, { connect, setNetworkName } from '../networks';
|
||||
import * as actions from '../actions';
|
||||
|
||||
describe('server reducer', () => {
|
||||
it('adds the server on CONNECT', () => {
|
||||
describe('network reducer', () => {
|
||||
it('adds the network on CONNECT', () => {
|
||||
let state = reducer(
|
||||
undefined,
|
||||
connect({ host: '127.0.0.1', nick: 'nick' })
|
||||
@ -13,10 +13,9 @@ describe('server reducer', () => {
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
connected: false,
|
||||
error: null,
|
||||
features: {}
|
||||
}
|
||||
});
|
||||
|
||||
@ -27,10 +26,9 @@ describe('server reducer', () => {
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
connected: false,
|
||||
error: null,
|
||||
features: {}
|
||||
}
|
||||
});
|
||||
|
||||
@ -44,24 +42,22 @@ describe('server reducer', () => {
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
connected: false,
|
||||
error: null,
|
||||
features: {}
|
||||
},
|
||||
'127.0.0.2': {
|
||||
name: 'srv',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: null
|
||||
}
|
||||
connected: false,
|
||||
error: null,
|
||||
features: {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the server on DISCONNECT', () => {
|
||||
it('removes the network on DISCONNECT', () => {
|
||||
let state = {
|
||||
srv: {},
|
||||
srv2: {}
|
||||
@ -69,7 +65,7 @@ describe('server reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv2'
|
||||
network: 'srv2'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
@ -77,14 +73,14 @@ describe('server reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('handles SET_SERVER_NAME', () => {
|
||||
it('handles SET_NETWORK_NAME', () => {
|
||||
let state = {
|
||||
srv: {
|
||||
name: 'cake'
|
||||
}
|
||||
};
|
||||
|
||||
state = reducer(state, setServerName('pie', 'srv'));
|
||||
state = reducer(state, setNetworkName('pie', 'srv'));
|
||||
|
||||
expect(state).toEqual({
|
||||
srv: {
|
||||
@ -100,7 +96,7 @@ describe('server reducer', () => {
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.SET_NICK,
|
||||
server: '127.0.0.1',
|
||||
network: '127.0.0.1',
|
||||
nick: 'nick2',
|
||||
editing: true
|
||||
});
|
||||
@ -121,13 +117,13 @@ describe('server reducer', () => {
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.SET_NICK,
|
||||
server: '127.0.0.1',
|
||||
network: '127.0.0.1',
|
||||
nick: 'nick2',
|
||||
editing: true
|
||||
});
|
||||
state = reducer(state, {
|
||||
type: actions.SET_NICK,
|
||||
server: '127.0.0.1',
|
||||
network: '127.0.0.1',
|
||||
nick: ''
|
||||
});
|
||||
|
||||
@ -147,7 +143,7 @@ describe('server reducer', () => {
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.socket.NICK,
|
||||
server: '127.0.0.1',
|
||||
network: '127.0.0.1',
|
||||
oldNick: 'nick',
|
||||
newNick: 'nick2'
|
||||
});
|
||||
@ -168,13 +164,13 @@ describe('server reducer', () => {
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.SET_NICK,
|
||||
server: '127.0.0.1',
|
||||
network: '127.0.0.1',
|
||||
nick: 'nick2',
|
||||
editing: true
|
||||
});
|
||||
state = reducer(state, {
|
||||
type: actions.socket.NICK_FAIL,
|
||||
server: '127.0.0.1'
|
||||
network: '127.0.0.1'
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
@ -186,25 +182,21 @@ describe('server reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the servers on SOCKET_SERVERS', () => {
|
||||
it('adds the networks on INIT', () => {
|
||||
let state = reducer(undefined, {
|
||||
type: actions.socket.SERVERS,
|
||||
data: [
|
||||
type: actions.INIT,
|
||||
networks: [
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
name: 'stuff',
|
||||
nick: 'nick',
|
||||
status: {
|
||||
connected: true
|
||||
}
|
||||
connected: true
|
||||
},
|
||||
{
|
||||
host: '127.0.0.2',
|
||||
name: 'stuffz',
|
||||
nick: 'nick2',
|
||||
status: {
|
||||
connected: false
|
||||
}
|
||||
connected: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@ -214,17 +206,15 @@ describe('server reducer', () => {
|
||||
name: 'stuff',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: true
|
||||
}
|
||||
connected: true,
|
||||
features: {}
|
||||
},
|
||||
'127.0.0.2': {
|
||||
name: 'stuffz',
|
||||
nick: 'nick2',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false
|
||||
}
|
||||
connected: false,
|
||||
features: {}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -236,7 +226,7 @@ describe('server reducer', () => {
|
||||
);
|
||||
state = reducer(state, {
|
||||
type: actions.socket.CONNECTION_UPDATE,
|
||||
server: '127.0.0.1',
|
||||
network: '127.0.0.1',
|
||||
connected: true
|
||||
});
|
||||
|
||||
@ -245,15 +235,14 @@ describe('server reducer', () => {
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: true
|
||||
}
|
||||
connected: true,
|
||||
features: {}
|
||||
}
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.socket.CONNECTION_UPDATE,
|
||||
server: '127.0.0.1',
|
||||
network: '127.0.0.1',
|
||||
connected: false,
|
||||
error: 'Bad stuff happened'
|
||||
});
|
||||
@ -263,10 +252,9 @@ describe('server reducer', () => {
|
||||
name: '127.0.0.1',
|
||||
nick: 'nick',
|
||||
editedNick: null,
|
||||
status: {
|
||||
connected: false,
|
||||
error: 'Bad stuff happened'
|
||||
}
|
||||
connected: false,
|
||||
error: 'Bad stuff happened',
|
||||
features: {}
|
||||
}
|
||||
});
|
||||
});
|
@ -7,17 +7,17 @@ describe('tab reducer', () => {
|
||||
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
|
||||
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan' },
|
||||
history: [{ server: 'srv', name: '#chan' }]
|
||||
selected: { network: 'srv', name: '#chan' },
|
||||
history: [{ network: 'srv', name: '#chan' }]
|
||||
});
|
||||
|
||||
state = reducer(state, setSelectedTab('srv', 'user1'));
|
||||
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: 'user1' },
|
||||
selected: { network: 'srv', name: 'user1' },
|
||||
history: [
|
||||
{ server: 'srv', name: '#chan' },
|
||||
{ server: 'srv', name: 'user1' }
|
||||
{ network: 'srv', name: '#chan' },
|
||||
{ network: 'srv', name: 'user1' }
|
||||
]
|
||||
});
|
||||
});
|
||||
@ -30,15 +30,15 @@ describe('tab reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.PART,
|
||||
server: 'srv',
|
||||
network: 'srv',
|
||||
channels: ['#chan']
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan3' },
|
||||
selected: { network: 'srv', name: '#chan3' },
|
||||
history: [
|
||||
{ server: 'srv1', name: 'bob' },
|
||||
{ server: 'srv', name: '#chan3' }
|
||||
{ network: 'srv1', name: 'bob' },
|
||||
{ network: 'srv', name: '#chan3' }
|
||||
]
|
||||
});
|
||||
});
|
||||
@ -51,21 +51,21 @@ describe('tab reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.CLOSE_PRIVATE_CHAT,
|
||||
server: 'srv1',
|
||||
network: 'srv1',
|
||||
nick: 'bob'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan3' },
|
||||
selected: { network: 'srv', name: '#chan3' },
|
||||
history: [
|
||||
{ server: 'srv', name: '#chan' },
|
||||
{ server: 'srv', name: '#chan' },
|
||||
{ server: 'srv', name: '#chan3' }
|
||||
{ network: 'srv', name: '#chan' },
|
||||
{ network: 'srv', name: '#chan' },
|
||||
{ network: 'srv', name: '#chan3' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('removes all tabs related to server from history on DISCONNECT', () => {
|
||||
it('removes all tabs related to network from history on DISCONNECT', () => {
|
||||
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
|
||||
state = reducer(state, setSelectedTab('srv1', 'bob'));
|
||||
state = reducer(state, setSelectedTab('srv', '#chan'));
|
||||
@ -73,38 +73,42 @@ describe('tab reducer', () => {
|
||||
|
||||
state = reducer(state, {
|
||||
type: actions.DISCONNECT,
|
||||
server: 'srv'
|
||||
network: 'srv'
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan3' },
|
||||
history: [{ server: 'srv1', name: 'bob' }]
|
||||
selected: { network: 'srv', name: '#chan3' },
|
||||
history: [{ network: 'srv1', name: 'bob' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the tab when navigating to a non-tab page', () => {
|
||||
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
|
||||
|
||||
state = reducer(state, locationChanged('settings'));
|
||||
state = reducer(state, locationChanged('settings', {}, {}));
|
||||
|
||||
expect(state).toEqual({
|
||||
selected: {},
|
||||
history: [{ server: 'srv', name: '#chan' }]
|
||||
history: [{ network: 'srv', name: '#chan' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('selects the tab and adds it to history when navigating to a tab', () => {
|
||||
const state = reducer(
|
||||
undefined,
|
||||
locationChanged('chat', {
|
||||
server: 'srv',
|
||||
name: '#chan'
|
||||
})
|
||||
locationChanged(
|
||||
'chat',
|
||||
{
|
||||
network: 'srv',
|
||||
name: '#chan'
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
expect(state).toEqual({
|
||||
selected: { server: 'srv', name: '#chan' },
|
||||
history: [{ server: 'srv', name: '#chan' }]
|
||||
selected: { network: 'srv', name: '#chan' },
|
||||
history: [{ network: 'srv', name: '#chan' }]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,11 +1,15 @@
|
||||
export const INIT = 'INIT';
|
||||
export const APP_SET = 'APP_SET';
|
||||
|
||||
export const INVITE = 'INVITE';
|
||||
export const JOIN = 'JOIN';
|
||||
export const KICK = 'KICK';
|
||||
export const KICKED = 'KICKED';
|
||||
export const PART = 'PART';
|
||||
export const SET_TOPIC = 'SET_TOPIC';
|
||||
|
||||
export const CHANNEL_SEARCH = 'CHANNEL_SEARCH';
|
||||
|
||||
export const INPUT_HISTORY_ADD = 'INPUT_HISTORY_ADD';
|
||||
export const INPUT_HISTORY_DECREMENT = 'INPUT_HISTORY_DECREMENT';
|
||||
export const INPUT_HISTORY_INCREMENT = 'INPUT_HISTORY_INCREMENT';
|
||||
@ -19,6 +23,9 @@ export const FETCH_MESSAGES = 'FETCH_MESSAGES';
|
||||
export const RAW = 'RAW';
|
||||
export const UPDATE_MESSAGE_HEIGHT = 'UPDATE_MESSAGE_HEIGHT';
|
||||
|
||||
export const OPEN_MODAL = 'OPEN_MODAL';
|
||||
export const CLOSE_MODAL = 'CLOSE_MODAL';
|
||||
|
||||
export const CLOSE_PRIVATE_CHAT = 'CLOSE_PRIVATE_CHAT';
|
||||
export const OPEN_PRIVATE_CHAT = 'OPEN_PRIVATE_CHAT';
|
||||
|
||||
@ -30,7 +37,7 @@ 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 SET_NETWORK_NAME = 'SET_NETWORK_NAME';
|
||||
export const WHOIS = 'WHOIS';
|
||||
|
||||
export const SET_CERT = 'SET_CERT';
|
||||
@ -60,18 +67,22 @@ function createSocketActions(types) {
|
||||
export const socket = createSocketActions([
|
||||
'cert_fail',
|
||||
'cert_success',
|
||||
'channels',
|
||||
'channel_forward',
|
||||
'channel_search',
|
||||
'connected',
|
||||
'connection_update',
|
||||
'error',
|
||||
'features',
|
||||
'join',
|
||||
'message',
|
||||
'mode',
|
||||
'nick_fail',
|
||||
'nick',
|
||||
'part',
|
||||
'kick',
|
||||
'pm',
|
||||
'quit',
|
||||
'search',
|
||||
'servers',
|
||||
'topic',
|
||||
'users'
|
||||
]);
|
||||
|
@ -9,13 +9,14 @@ export const getWindowWidth = state => state.app.windowWidth;
|
||||
export const getConnectDefaults = state => state.app.connectDefaults;
|
||||
|
||||
const initialState = {
|
||||
connected: true,
|
||||
connected: false,
|
||||
wrapWidth: 0,
|
||||
charWidth: 0,
|
||||
windowWidth: 0,
|
||||
connectDefaults: {
|
||||
name: '',
|
||||
address: '',
|
||||
host: '',
|
||||
port: '',
|
||||
channels: [],
|
||||
ssl: false,
|
||||
password: false,
|
||||
@ -29,13 +30,25 @@ const initialState = {
|
||||
|
||||
export default createReducer(initialState, {
|
||||
[actions.APP_SET](state, { key, value }) {
|
||||
state[key] = value;
|
||||
if (typeof key === 'object') {
|
||||
Object.assign(state, key);
|
||||
} else {
|
||||
state[key] = value;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.CONNECTED](state, { connected }) {
|
||||
state.connected = connected;
|
||||
},
|
||||
|
||||
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
|
||||
state.wrapWidth = action.wrapWidth;
|
||||
state.charWidth = action.charWidth;
|
||||
state.windowWidth = action.windowWidth;
|
||||
},
|
||||
|
||||
[actions.INIT](state, { app }) {
|
||||
Object.assign(state, app);
|
||||
}
|
||||
});
|
||||
|
||||
@ -47,14 +60,6 @@ export function appSet(key, value) {
|
||||
};
|
||||
}
|
||||
|
||||
export function setConnected(connected) {
|
||||
return appSet('connected', connected);
|
||||
}
|
||||
|
||||
export function setCharWidth(width) {
|
||||
return appSet('charWidth', width);
|
||||
}
|
||||
|
||||
export function setConnectDefaults(defaults) {
|
||||
return appSet('connectDefaults', defaults);
|
||||
}
|
||||
|
47
client/js/state/channelSearch.js
Normal file
47
client/js/state/channelSearch.js
Normal file
@ -0,0 +1,47 @@
|
||||
import createReducer from 'utils/createReducer';
|
||||
import * as actions from 'state/actions';
|
||||
|
||||
const initialState = {
|
||||
results: [],
|
||||
end: false,
|
||||
topCache: {}
|
||||
};
|
||||
|
||||
export default createReducer(initialState, {
|
||||
[actions.socket.CHANNEL_SEARCH](state, { results, start, network, q }) {
|
||||
if (results) {
|
||||
state.end = false;
|
||||
|
||||
if (start > 0) {
|
||||
state.results.push(...results);
|
||||
} else {
|
||||
state.results = results;
|
||||
|
||||
if (!q) {
|
||||
state.topCache[network] = results;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.end = true;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.OPEN_MODAL](state, { name, payload }) {
|
||||
if (name === 'channel') {
|
||||
state.results = state.topCache[payload] || [];
|
||||
state.end = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function searchChannels(network, q, start) {
|
||||
return {
|
||||
type: actions.CHANNEL_SEARCH,
|
||||
network,
|
||||
q,
|
||||
socket: {
|
||||
type: 'channel_search',
|
||||
data: { network, q, start }
|
||||
}
|
||||
};
|
||||
}
|
@ -2,7 +2,7 @@ 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 { trimPrefixChar, find, findIndex } from 'utils';
|
||||
import { getSelectedTab, updateSelection } from './tab';
|
||||
import * as actions from './actions';
|
||||
|
||||
@ -56,13 +56,18 @@ function removeUser(users, nick) {
|
||||
}
|
||||
}
|
||||
|
||||
function init(state, server, channel) {
|
||||
if (!state[server]) {
|
||||
state[server] = {};
|
||||
function init(state, network, channel) {
|
||||
if (!state[network]) {
|
||||
state[network] = {};
|
||||
}
|
||||
if (channel && !state[server][channel]) {
|
||||
state[server][channel] = { users: [] };
|
||||
if (channel && !state[network][channel]) {
|
||||
state[network][channel] = {
|
||||
name: channel,
|
||||
users: [],
|
||||
joined: false
|
||||
};
|
||||
}
|
||||
return state[network][channel];
|
||||
}
|
||||
|
||||
export function compareUsers(a, b) {
|
||||
@ -91,24 +96,22 @@ export function compareUsers(a, b) {
|
||||
|
||||
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 getSortedChannels = createSelector(getChannels, channels =>
|
||||
sortBy(
|
||||
Object.keys(channels).map(network => ({
|
||||
address: network,
|
||||
channels: sortBy(channels[network], channel =>
|
||||
trimPrefixChar(channel.name, '#').toLowerCase()
|
||||
)
|
||||
})),
|
||||
network => network.address.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
export const getSelectedChannel = createSelector(
|
||||
getSelectedTab,
|
||||
getChannels,
|
||||
(tab, channels) => get(channels, [tab.server, tab.name])
|
||||
(tab, channels) => get(channels, [tab.network, tab.name])
|
||||
);
|
||||
|
||||
export const getSelectedChannelUsers = createSelector(
|
||||
@ -124,32 +127,53 @@ export const getSelectedChannelUsers = createSelector(
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.PART](state, { server, channels }) {
|
||||
channels.forEach(channel => delete state[server][channel]);
|
||||
[actions.JOIN](state, { network, channels }) {
|
||||
channels.forEach(channel => init(state, network, channel));
|
||||
},
|
||||
|
||||
[actions.socket.JOIN](state, { server, channels, user }) {
|
||||
[actions.PART](state, { network, channels }) {
|
||||
channels.forEach(channel => delete state[network][channel]);
|
||||
},
|
||||
|
||||
[actions.socket.JOIN](state, { network, channels, user }) {
|
||||
const channel = channels[0];
|
||||
init(state, server, channel);
|
||||
state[server][channel].users.push(createUser(user));
|
||||
const chan = init(state, network, channel);
|
||||
chan.name = channel;
|
||||
chan.joined = true;
|
||||
chan.users.push(createUser(user));
|
||||
},
|
||||
|
||||
[actions.socket.PART](state, { server, channel, user }) {
|
||||
if (state[server][channel]) {
|
||||
removeUser(state[server][channel].users, user);
|
||||
[actions.socket.CHANNEL_FORWARD](state, action) {
|
||||
init(state, action.network, action.new);
|
||||
delete state[action.network][action.old];
|
||||
},
|
||||
|
||||
[actions.socket.PART](state, { network, channel, user }) {
|
||||
if (state[network][channel]) {
|
||||
removeUser(state[network][channel].users, user);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.QUIT](state, { server, user }) {
|
||||
Object.keys(state[server]).forEach(channel => {
|
||||
removeUser(state[server][channel].users, user);
|
||||
[actions.socket.QUIT](state, { network, user }) {
|
||||
Object.keys(state[network]).forEach(channel => {
|
||||
removeUser(state[network][channel].users, user);
|
||||
});
|
||||
},
|
||||
|
||||
[actions.socket.NICK](state, { server, oldNick, newNick }) {
|
||||
Object.keys(state[server]).forEach(channel => {
|
||||
[actions.KICKED](state, { network, channel, user, self }) {
|
||||
const chan = state[network][channel];
|
||||
if (self) {
|
||||
chan.joined = false;
|
||||
chan.users = [];
|
||||
} else {
|
||||
removeUser(chan.users, user);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.NICK](state, { network, oldNick, newNick }) {
|
||||
Object.keys(state[network]).forEach(channel => {
|
||||
const user = find(
|
||||
state[server][channel].users,
|
||||
state[network][channel].users,
|
||||
u => u.nick === oldNick
|
||||
);
|
||||
if (user) {
|
||||
@ -159,18 +183,8 @@ export default createReducer(
|
||||
});
|
||||
},
|
||||
|
||||
[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);
|
||||
[actions.socket.MODE](state, { network, channel, user, remove, add }) {
|
||||
const u = find(state[network][channel].users, v => v.nick === user);
|
||||
if (u) {
|
||||
if (remove) {
|
||||
let j = remove.length;
|
||||
@ -187,18 +201,31 @@ export default createReducer(
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.CHANNELS](state, { data }) {
|
||||
if (data) {
|
||||
data.forEach(({ server, name, topic }) => {
|
||||
init(state, server, name);
|
||||
state[server][name].topic = topic;
|
||||
});
|
||||
}
|
||||
[actions.socket.TOPIC](state, { network, channel, topic }) {
|
||||
state[network][channel].topic = topic;
|
||||
},
|
||||
|
||||
[actions.socket.SERVERS](state, { data }) {
|
||||
if (data) {
|
||||
data.forEach(({ host }) => init(state, host));
|
||||
[actions.socket.USERS](state, { network, channel, users }) {
|
||||
state[network][channel].users = users.map(nick => loadUser(nick));
|
||||
},
|
||||
|
||||
[actions.INIT](state, { networks, channels, users }) {
|
||||
if (networks) {
|
||||
networks.forEach(({ host }) => init(state, host));
|
||||
}
|
||||
|
||||
if (channels) {
|
||||
channels.forEach(({ network, name, topic, joined }) => {
|
||||
const chan = init(state, network, name);
|
||||
chan.joined = joined;
|
||||
chan.topic = topic;
|
||||
});
|
||||
}
|
||||
|
||||
if (users) {
|
||||
state[users.network][users.channel].users = users.users.map(nick =>
|
||||
loadUser(nick)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@ -206,74 +233,100 @@ export default createReducer(
|
||||
init(state, host);
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
delete state[server];
|
||||
[actions.DISCONNECT](state, { network }) {
|
||||
delete state[network];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function join(channels, server) {
|
||||
export function join(channels, network, selectFirst = true) {
|
||||
return {
|
||||
type: actions.JOIN,
|
||||
channels,
|
||||
server,
|
||||
network,
|
||||
selectFirst,
|
||||
socket: {
|
||||
type: 'join',
|
||||
data: { channels, server }
|
||||
data: { channels, network }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function part(channels, server) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
export function part(channels, network) {
|
||||
return (dispatch, getState) => {
|
||||
const action = {
|
||||
type: actions.PART,
|
||||
channels,
|
||||
server,
|
||||
socket: {
|
||||
network
|
||||
};
|
||||
|
||||
const state = getState().channels[network];
|
||||
const joined = channels.filter(c => state[c] && state[c].joined);
|
||||
|
||||
if (joined.length > 0) {
|
||||
action.socket = {
|
||||
type: 'part',
|
||||
data: { channels, server }
|
||||
}
|
||||
});
|
||||
data: {
|
||||
channels: joined,
|
||||
network
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
dispatch(action);
|
||||
dispatch(updateSelection());
|
||||
};
|
||||
}
|
||||
|
||||
export function invite(user, channel, server) {
|
||||
export function invite(user, channel, network) {
|
||||
return {
|
||||
type: actions.INVITE,
|
||||
user,
|
||||
channel,
|
||||
server,
|
||||
network,
|
||||
socket: {
|
||||
type: 'invite',
|
||||
data: { user, channel, server }
|
||||
data: { user, channel, network }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function kick(user, channel, server) {
|
||||
export function kick(user, channel, network) {
|
||||
return {
|
||||
type: actions.KICK,
|
||||
user,
|
||||
channel,
|
||||
server,
|
||||
network,
|
||||
socket: {
|
||||
type: 'kick',
|
||||
data: { user, channel, server }
|
||||
data: { user, channel, network }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setTopic(topic, channel, server) {
|
||||
export function kicked(network, channel, user) {
|
||||
return (dispatch, getState) => {
|
||||
const nick = getState().networks[network]?.nick;
|
||||
|
||||
dispatch({
|
||||
type: actions.KICKED,
|
||||
network,
|
||||
channel,
|
||||
user,
|
||||
self: nick === user
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function setTopic(topic, channel, network) {
|
||||
return {
|
||||
type: actions.SET_TOPIC,
|
||||
topic,
|
||||
channel,
|
||||
server,
|
||||
network,
|
||||
socket: {
|
||||
type: 'topic',
|
||||
data: { topic, channel, server }
|
||||
data: { topic, channel, network }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import app from './app';
|
||||
import channels from './channels';
|
||||
import channelSearch from './channelSearch';
|
||||
import input from './input';
|
||||
import messages from './messages';
|
||||
import modals from './modals';
|
||||
import networks from './networks';
|
||||
import privateChats from './privateChats';
|
||||
import search from './search';
|
||||
import servers from './servers';
|
||||
import settings from './settings';
|
||||
import tab from './tab';
|
||||
import ui from './ui';
|
||||
@ -18,11 +20,13 @@ export default function createReducer(router) {
|
||||
router,
|
||||
app,
|
||||
channels,
|
||||
channelSearch,
|
||||
input,
|
||||
messages,
|
||||
modals,
|
||||
networks,
|
||||
privateChats,
|
||||
search,
|
||||
servers,
|
||||
settings,
|
||||
tab,
|
||||
ui
|
||||
|
@ -5,8 +5,11 @@ import {
|
||||
messageHeight,
|
||||
linkify,
|
||||
timestamp,
|
||||
isChannel
|
||||
isChannel,
|
||||
formatDate,
|
||||
unix
|
||||
} from 'utils';
|
||||
import colorify from 'utils/colorify';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { getApp } from './app';
|
||||
import { getSelectedTab } from './tab';
|
||||
@ -18,9 +21,9 @@ export const getSelectedMessages = createSelector(
|
||||
getSelectedTab,
|
||||
getMessages,
|
||||
(tab, messages) => {
|
||||
const target = tab.name || tab.server;
|
||||
if (has(messages, [tab.server, target])) {
|
||||
return messages[tab.server][target];
|
||||
const target = tab.name || tab.network;
|
||||
if (has(messages, [tab.network, target])) {
|
||||
return messages[tab.network][target];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@ -34,50 +37,397 @@ export const getHasMoreMessages = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
function init(state, server, tab) {
|
||||
if (!state[server]) {
|
||||
state[server] = {};
|
||||
function init(state, network, tab) {
|
||||
if (!state[network]) {
|
||||
state[network] = {};
|
||||
}
|
||||
if (!state[server][tab]) {
|
||||
state[server][tab] = [];
|
||||
if (!state[network][tab]) {
|
||||
state[network][tab] = [];
|
||||
}
|
||||
}
|
||||
|
||||
function initNetworks(state, networks = []) {
|
||||
networks.forEach(({ host }) => {
|
||||
state[host] = {};
|
||||
});
|
||||
}
|
||||
|
||||
const collapsedEvents = ['join', 'part', 'quit', 'nick'];
|
||||
|
||||
function shouldCollapse(msg1, msg2) {
|
||||
return (
|
||||
msg1.events &&
|
||||
msg2.events &&
|
||||
collapsedEvents.indexOf(msg1.events[0].type) !== -1 &&
|
||||
collapsedEvents.indexOf(msg2.events[0].type) !== -1
|
||||
);
|
||||
}
|
||||
|
||||
const blocks = {
|
||||
nick: nick => ({ type: 'nick', text: nick }),
|
||||
text: text => ({ type: 'text', text }),
|
||||
events: count => ({ type: 'events', text: `${count} more` })
|
||||
};
|
||||
|
||||
const eventVerbs = {
|
||||
join: 'joined',
|
||||
part: 'left',
|
||||
quit: 'quit'
|
||||
};
|
||||
|
||||
function renderEvent(result, type, events) {
|
||||
const ending = eventVerbs[type];
|
||||
|
||||
if (result.length > 1) {
|
||||
result[result.length - 1].text += ', ';
|
||||
}
|
||||
|
||||
if (events.length === 1) {
|
||||
result.push(blocks.nick(events[0][0]));
|
||||
result.push(blocks.text(` ${ending}`));
|
||||
} else if (events.length === 2) {
|
||||
result.push(blocks.nick(events[0][0]));
|
||||
result.push(blocks.text(' and '));
|
||||
result.push(blocks.nick(events[1][0]));
|
||||
result.push(blocks.text(` ${ending}`));
|
||||
} else if (events.length > 2) {
|
||||
result.push(blocks.nick(events[0][0]));
|
||||
result.push(blocks.text(', '));
|
||||
result.push(blocks.nick(events[1][0]));
|
||||
result.push(blocks.text(' and '));
|
||||
result.push(blocks.events(events.length - 2));
|
||||
result.push(blocks.text(` ${ending}`));
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
const first = events[0];
|
||||
|
||||
if (first.type === 'kick') {
|
||||
const [kicked, by] = first.params;
|
||||
|
||||
return [blocks.nick(by), blocks.text(' kicked '), blocks.nick(kicked)];
|
||||
}
|
||||
|
||||
if (first.type === 'topic') {
|
||||
const [nick, topic] = first.params;
|
||||
|
||||
if (!topic) {
|
||||
return [blocks.nick(nick), blocks.text(' cleared the topic')];
|
||||
}
|
||||
|
||||
return [
|
||||
blocks.nick(nick),
|
||||
blocks.text(' changed the topic to: '),
|
||||
...colorify(linkify(topic))
|
||||
];
|
||||
}
|
||||
|
||||
const byType = {};
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const event = events[i];
|
||||
const [nick] = event.params;
|
||||
|
||||
if (!byType[event.type]) {
|
||||
byType[event.type] = [event.params];
|
||||
} else if (byType[event.type].indexOf(nick) === -1) {
|
||||
byType[event.type].push(event.params);
|
||||
}
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
if (byType.join) {
|
||||
renderEvent(result, 'join', byType.join);
|
||||
}
|
||||
|
||||
if (byType.part) {
|
||||
renderEvent(result, 'part', byType.part);
|
||||
}
|
||||
|
||||
if (byType.quit) {
|
||||
renderEvent(result, 'quit', byType.quit);
|
||||
}
|
||||
|
||||
if (byType.nick) {
|
||||
if (result.length > 1) {
|
||||
result[result.length - 1].text += ', ';
|
||||
}
|
||||
|
||||
const [oldNick, newNick] = byType.nick[0];
|
||||
|
||||
result.push(blocks.nick(oldNick));
|
||||
result.push(blocks.text(' changed nick to '));
|
||||
result.push(blocks.nick(newNick));
|
||||
|
||||
if (byType.nick.length > 1) {
|
||||
result.push(blocks.text(' and '));
|
||||
result.push(blocks.events(byType.nick.length - 1));
|
||||
result.push(blocks.text(' changed nick'));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
let nextID = 0;
|
||||
|
||||
function initMessage(
|
||||
state,
|
||||
message,
|
||||
network,
|
||||
tab,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth,
|
||||
prepend
|
||||
) {
|
||||
const messages = state[network][tab];
|
||||
|
||||
if (messages.length > 0 && !prepend) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (shouldCollapse(lastMessage, message)) {
|
||||
lastMessage.events.push(message.events[0]);
|
||||
lastMessage.content = renderEvents(lastMessage.events);
|
||||
|
||||
[lastMessage.breakpoints, lastMessage.length] = findBreakpoints(
|
||||
lastMessage.content
|
||||
);
|
||||
lastMessage.height = messageHeight(
|
||||
lastMessage,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
6 * charWidth,
|
||||
windowWidth
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.time) {
|
||||
message.date = new Date(message.time * 1000);
|
||||
} else {
|
||||
message.date = new Date();
|
||||
}
|
||||
|
||||
message.time = timestamp(message.date);
|
||||
|
||||
if (!message.id) {
|
||||
message.id = nextID;
|
||||
nextID++;
|
||||
}
|
||||
|
||||
if (tab.charAt(0) === '#') {
|
||||
message.channel = true;
|
||||
}
|
||||
|
||||
if (message.events) {
|
||||
message.type = 'info';
|
||||
message.content = renderEvents(message.events);
|
||||
} else {
|
||||
message.content = message.content || '';
|
||||
// Collapse multiple adjacent spaces into a single one
|
||||
message.content = message.content.replace(/\s\s+/g, ' ');
|
||||
|
||||
if (message.content.indexOf('\x01ACTION') === 0) {
|
||||
const { from } = message;
|
||||
message.from = null;
|
||||
message.type = 'action';
|
||||
message.content = from + message.content.slice(7, -1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.events) {
|
||||
message.content = colorify(linkify(message.content));
|
||||
}
|
||||
|
||||
[message.breakpoints, message.length] = findBreakpoints(message.content);
|
||||
message.height = messageHeight(
|
||||
message,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
6 * charWidth,
|
||||
windowWidth
|
||||
);
|
||||
message.indent = 6 * charWidth;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function createDateMessage(date) {
|
||||
const message = {
|
||||
id: nextID,
|
||||
type: 'date',
|
||||
content: formatDate(date),
|
||||
height: 40
|
||||
};
|
||||
|
||||
nextID++;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function isSameDay(d1, d2) {
|
||||
return (
|
||||
d1.getDate() === d2.getDate() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getFullYear() === d2.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
function reducerPrependMessages(
|
||||
state,
|
||||
messages,
|
||||
network,
|
||||
tab,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth
|
||||
) {
|
||||
const msgs = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
initMessage(
|
||||
state,
|
||||
message,
|
||||
network,
|
||||
tab,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth,
|
||||
true
|
||||
);
|
||||
|
||||
if (i > 0 && !isSameDay(messages[i - 1].date, message.date)) {
|
||||
msgs.push(createDateMessage(message.date));
|
||||
}
|
||||
msgs.push(message);
|
||||
}
|
||||
|
||||
const m = state[network][tab];
|
||||
|
||||
if (m.length > 0) {
|
||||
const lastNewMessage = msgs[msgs.length - 1];
|
||||
const firstMessage = m[0];
|
||||
if (
|
||||
firstMessage.date &&
|
||||
!isSameDay(firstMessage.date, lastNewMessage.date)
|
||||
) {
|
||||
msgs.push(createDateMessage(firstMessage.date));
|
||||
}
|
||||
}
|
||||
|
||||
m.unshift(...msgs);
|
||||
}
|
||||
|
||||
function reducerAddMessage(message, network, tab, state) {
|
||||
const messages = state[network][tab];
|
||||
|
||||
if (messages.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.date && !isSameDay(lastMessage.date, message.date)) {
|
||||
messages.push(createDateMessage(message.date));
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.ADD_MESSAGE](state, { server, tab, message }) {
|
||||
init(state, server, tab);
|
||||
state[server][tab].push(message);
|
||||
[actions.ADD_MESSAGE](
|
||||
state,
|
||||
{ network, tab, message, wrapWidth, charWidth, windowWidth }
|
||||
) {
|
||||
init(state, network, tab);
|
||||
|
||||
const shouldAdd = initMessage(
|
||||
state,
|
||||
message,
|
||||
network,
|
||||
tab,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth
|
||||
);
|
||||
if (shouldAdd) {
|
||||
reducerAddMessage(message, network, tab, state);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.ADD_MESSAGES](state, { server, tab, messages, prepend }) {
|
||||
[actions.ADD_MESSAGES](
|
||||
state,
|
||||
{ network, tab, messages, prepend, wrapWidth, charWidth, windowWidth }
|
||||
) {
|
||||
if (prepend) {
|
||||
init(state, server, tab);
|
||||
state[server][tab].unshift(...messages);
|
||||
init(state, network, tab);
|
||||
reducerPrependMessages(
|
||||
state,
|
||||
messages,
|
||||
network,
|
||||
tab,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth
|
||||
);
|
||||
} else {
|
||||
if (!messages[0].tab) {
|
||||
init(state, network, tab);
|
||||
}
|
||||
|
||||
messages.forEach(message => {
|
||||
init(state, server, message.tab || tab);
|
||||
state[server][message.tab || tab].push(message);
|
||||
if (message.tab) {
|
||||
init(state, network, message.tab);
|
||||
}
|
||||
|
||||
const shouldAdd = initMessage(
|
||||
state,
|
||||
message,
|
||||
network,
|
||||
message.tab || tab,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth
|
||||
);
|
||||
if (shouldAdd) {
|
||||
reducerAddMessage(message, network, message.tab || tab, state);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
delete state[server];
|
||||
[actions.DISCONNECT](state, { network }) {
|
||||
delete state[network];
|
||||
},
|
||||
|
||||
[actions.PART](state, { server, channels }) {
|
||||
channels.forEach(channel => delete state[server][channel]);
|
||||
[actions.PART](state, { network, channels }) {
|
||||
channels.forEach(channel => delete state[network][channel]);
|
||||
},
|
||||
|
||||
[actions.CLOSE_PRIVATE_CHAT](state, { network, nick }) {
|
||||
delete state[network][nick];
|
||||
},
|
||||
|
||||
[actions.socket.CHANNEL_FORWARD](state, { network, old }) {
|
||||
if (state[network]) {
|
||||
delete state[network][old];
|
||||
}
|
||||
},
|
||||
|
||||
[actions.UPDATE_MESSAGE_HEIGHT](
|
||||
state,
|
||||
{ wrapWidth, charWidth, windowWidth }
|
||||
) {
|
||||
Object.keys(state).forEach(server =>
|
||||
Object.keys(state[server]).forEach(target =>
|
||||
state[server][target].forEach(message => {
|
||||
Object.keys(state).forEach(network =>
|
||||
Object.keys(state[network]).forEach(target =>
|
||||
state[network][target].forEach(message => {
|
||||
if (message.type === 'date') {
|
||||
return;
|
||||
}
|
||||
|
||||
message.height = messageHeight(
|
||||
message,
|
||||
wrapWidth,
|
||||
@ -90,63 +440,19 @@ export default createReducer(
|
||||
);
|
||||
},
|
||||
|
||||
[actions.socket.SERVERS](state, { data }) {
|
||||
if (data) {
|
||||
data.forEach(({ host }) => {
|
||||
state[host] = {};
|
||||
});
|
||||
}
|
||||
[actions.INIT](state, { networks }) {
|
||||
initNetworks(state, networks);
|
||||
},
|
||||
|
||||
[actions.socket.NETWORKS](state, { data }) {
|
||||
initNetworks(state, data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let nextID = 0;
|
||||
|
||||
function initMessage(message, tab, state) {
|
||||
if (message.time) {
|
||||
message.time = timestamp(new Date(message.time * 1000));
|
||||
} else {
|
||||
message.time = timestamp();
|
||||
}
|
||||
|
||||
if (!message.id) {
|
||||
message.id = nextID;
|
||||
nextID++;
|
||||
}
|
||||
|
||||
if (tab.charAt(0) === '#') {
|
||||
message.channel = true;
|
||||
}
|
||||
|
||||
// Collapse multiple adjacent spaces into a single one
|
||||
message.content = message.content.replace(/\s\s+/g, ' ');
|
||||
|
||||
if (message.content.indexOf('\x01ACTION') === 0) {
|
||||
const { from } = message;
|
||||
message.from = null;
|
||||
message.type = 'action';
|
||||
message.content = from + message.content.slice(7, -1);
|
||||
}
|
||||
|
||||
const { wrapWidth, charWidth, windowWidth } = getApp(state);
|
||||
|
||||
message.length = message.content.length;
|
||||
message.breakpoints = findBreakpoints(message.content);
|
||||
message.height = messageHeight(
|
||||
message,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
6 * charWidth,
|
||||
windowWidth
|
||||
);
|
||||
message.content = linkify(message.content);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function getMessageTab(server, to) {
|
||||
export function getMessageTab(network, to) {
|
||||
if (!to || to === '*' || (!isChannel(to) && to.indexOf('.') !== -1)) {
|
||||
return server;
|
||||
return network;
|
||||
}
|
||||
return to;
|
||||
}
|
||||
@ -161,13 +467,13 @@ export function fetchMessages() {
|
||||
}
|
||||
|
||||
const tab = state.tab.selected;
|
||||
if (isChannel(tab)) {
|
||||
if (tab.name) {
|
||||
dispatch({
|
||||
type: actions.FETCH_MESSAGES,
|
||||
socket: {
|
||||
type: 'fetch_messages',
|
||||
data: {
|
||||
server: tab.server,
|
||||
network: tab.network,
|
||||
channel: tab.name,
|
||||
next: first.id
|
||||
}
|
||||
@ -177,10 +483,10 @@ export function fetchMessages() {
|
||||
};
|
||||
}
|
||||
|
||||
export function addFetchedMessages(server, tab) {
|
||||
export function addFetchedMessages(network, tab) {
|
||||
return {
|
||||
type: actions.ADD_FETCHED_MESSAGES,
|
||||
server,
|
||||
network,
|
||||
tab
|
||||
};
|
||||
}
|
||||
@ -194,44 +500,50 @@ export function updateMessageHeight(wrapWidth, charWidth, windowWidth) {
|
||||
};
|
||||
}
|
||||
|
||||
export function sendMessage(content, to, server) {
|
||||
export function sendMessage(content, to, network) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const { wrapWidth, charWidth, windowWidth } = getApp(state);
|
||||
|
||||
dispatch({
|
||||
type: actions.ADD_MESSAGE,
|
||||
server,
|
||||
network,
|
||||
tab: to,
|
||||
message: initMessage(
|
||||
{
|
||||
from: state.servers[server].nick,
|
||||
content
|
||||
},
|
||||
to,
|
||||
state
|
||||
),
|
||||
message: {
|
||||
from: state.networks[network].nick,
|
||||
content
|
||||
},
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth,
|
||||
socket: {
|
||||
type: 'message',
|
||||
data: { content, to, server }
|
||||
data: { content, to, network }
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function addMessage(message, server, to) {
|
||||
const tab = getMessageTab(server, to);
|
||||
export function addMessage(message, network, to) {
|
||||
const tab = getMessageTab(network, to);
|
||||
|
||||
return (dispatch, getState) => {
|
||||
const { wrapWidth, charWidth, windowWidth } = getApp(getState());
|
||||
|
||||
return (dispatch, getState) =>
|
||||
dispatch({
|
||||
type: actions.ADD_MESSAGE,
|
||||
server,
|
||||
network,
|
||||
tab,
|
||||
message: initMessage(message, tab, getState())
|
||||
message,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function addMessages(messages, server, to, prepend, next) {
|
||||
const tab = getMessageTab(server, to);
|
||||
export function addMessages(messages, network, to, prepend, next) {
|
||||
const tab = getMessageTab(network, to);
|
||||
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
@ -241,39 +553,76 @@ export function addMessages(messages, server, to, prepend, next) {
|
||||
messages[0].next = true;
|
||||
}
|
||||
|
||||
messages.forEach(message =>
|
||||
initMessage(message, message.tab || tab, state)
|
||||
);
|
||||
const { wrapWidth, charWidth, windowWidth } = getApp(state);
|
||||
|
||||
dispatch({
|
||||
type: actions.ADD_MESSAGES,
|
||||
server,
|
||||
network,
|
||||
tab,
|
||||
messages,
|
||||
prepend
|
||||
prepend,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
windowWidth
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function broadcast(message, server, channels) {
|
||||
export function addEvent(network, tab, type, ...params) {
|
||||
return addMessage(
|
||||
{
|
||||
type: 'info',
|
||||
events: [
|
||||
{
|
||||
type,
|
||||
params,
|
||||
time: unix()
|
||||
}
|
||||
]
|
||||
},
|
||||
network,
|
||||
tab
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastEvent(network, channels, type, ...params) {
|
||||
const now = unix();
|
||||
|
||||
return addMessages(
|
||||
channels.map(channel => ({
|
||||
type: 'info',
|
||||
tab: channel,
|
||||
events: [
|
||||
{
|
||||
type,
|
||||
params,
|
||||
time: now
|
||||
}
|
||||
]
|
||||
})),
|
||||
network
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcast(message, network, channels) {
|
||||
return addMessages(
|
||||
channels.map(channel => ({
|
||||
tab: channel,
|
||||
content: message,
|
||||
type: 'info'
|
||||
})),
|
||||
server
|
||||
network
|
||||
);
|
||||
}
|
||||
|
||||
export function print(message, server, channel, type) {
|
||||
export function print(message, network, channel, type) {
|
||||
if (Array.isArray(message)) {
|
||||
return addMessages(
|
||||
message.map(line => ({
|
||||
content: line,
|
||||
type
|
||||
})),
|
||||
server,
|
||||
network,
|
||||
channel
|
||||
);
|
||||
}
|
||||
@ -283,32 +632,32 @@ export function print(message, server, channel, type) {
|
||||
content: message,
|
||||
type
|
||||
},
|
||||
server,
|
||||
network,
|
||||
channel
|
||||
);
|
||||
}
|
||||
|
||||
export function inform(message, server, channel) {
|
||||
return print(message, server, channel, 'info');
|
||||
export function inform(message, network, channel) {
|
||||
return print(message, network, channel, 'info');
|
||||
}
|
||||
|
||||
export function runCommand(command, channel, server) {
|
||||
export function runCommand(command, channel, network) {
|
||||
return {
|
||||
type: actions.COMMAND,
|
||||
command,
|
||||
channel,
|
||||
server
|
||||
network
|
||||
};
|
||||
}
|
||||
|
||||
export function raw(message, server) {
|
||||
export function raw(message, network) {
|
||||
return {
|
||||
type: actions.RAW,
|
||||
message,
|
||||
server,
|
||||
network,
|
||||
socket: {
|
||||
type: 'raw',
|
||||
data: { message, server }
|
||||
data: { message, network }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
47
client/js/state/modals.js
Normal file
47
client/js/state/modals.js
Normal file
@ -0,0 +1,47 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import * as actions from './actions';
|
||||
|
||||
export const getModals = state => state.modals;
|
||||
|
||||
export const getHasOpenModals = createSelector(getModals, modals => {
|
||||
const keys = Object.keys(modals);
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (modals[keys[i]].isOpen) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.OPEN_MODAL](state, { name, payload = {} }) {
|
||||
state[name] = {
|
||||
isOpen: true,
|
||||
payload
|
||||
};
|
||||
},
|
||||
|
||||
[actions.CLOSE_MODAL](state, { name }) {
|
||||
state[name].isOpen = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function openModal(name, payload) {
|
||||
return {
|
||||
type: actions.OPEN_MODAL,
|
||||
name,
|
||||
payload
|
||||
};
|
||||
}
|
||||
|
||||
export function closeModal(name) {
|
||||
return {
|
||||
type: actions.CLOSE_MODAL,
|
||||
name
|
||||
};
|
||||
}
|
229
client/js/state/networks.js
Normal file
229
client/js/state/networks.js
Normal file
@ -0,0 +1,229 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import get from 'lodash/get';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { getSelectedTab, updateSelection } from './tab';
|
||||
import * as actions from './actions';
|
||||
|
||||
export const getNetworks = state => state.networks;
|
||||
|
||||
export const getCurrentNick = createSelector(
|
||||
getNetworks,
|
||||
getSelectedTab,
|
||||
(networks, tab) => {
|
||||
if (!networks[tab.network]) {
|
||||
return;
|
||||
}
|
||||
const { editedNick } = networks[tab.network];
|
||||
if (editedNick === null) {
|
||||
return networks[tab.network].nick;
|
||||
}
|
||||
return editedNick;
|
||||
}
|
||||
);
|
||||
|
||||
export const getCurrentNetworkName = createSelector(
|
||||
getNetworks,
|
||||
getSelectedTab,
|
||||
(networks, tab) => get(networks, [tab.network, 'name'])
|
||||
);
|
||||
|
||||
export const getCurrentNetworkError = createSelector(
|
||||
getNetworks,
|
||||
getSelectedTab,
|
||||
(networks, tab) => get(networks, [tab.network, 'error'], null)
|
||||
);
|
||||
|
||||
export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.CONNECT](state, { host, nick, name }) {
|
||||
if (!state[host]) {
|
||||
state[host] = {
|
||||
nick,
|
||||
editedNick: null,
|
||||
name: name || host,
|
||||
connected: false,
|
||||
error: null,
|
||||
features: {}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { network }) {
|
||||
delete state[network];
|
||||
},
|
||||
|
||||
[actions.SET_NETWORK_NAME](state, { network, name }) {
|
||||
state[network].name = name;
|
||||
},
|
||||
|
||||
[actions.SET_NICK](state, { network, nick, editing }) {
|
||||
if (editing) {
|
||||
state[network].editedNick = nick;
|
||||
} else if (nick === '') {
|
||||
state[network].editedNick = null;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.NICK](state, { network, oldNick, newNick }) {
|
||||
if (!oldNick || oldNick === state[network].nick) {
|
||||
state[network].nick = newNick;
|
||||
state[network].editedNick = null;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.NICK_FAIL](state, { network }) {
|
||||
state[network].editedNick = null;
|
||||
},
|
||||
|
||||
[actions.INIT](state, { networks }) {
|
||||
if (networks) {
|
||||
networks.forEach(
|
||||
({ host, name = host, nick, connected, error, features = {} }) => {
|
||||
state[host] = {
|
||||
name,
|
||||
nick,
|
||||
connected,
|
||||
error,
|
||||
features,
|
||||
editedNick: null
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.CONNECTION_UPDATE](state, { network, connected, error }) {
|
||||
if (state[network]) {
|
||||
state[network].connected = connected;
|
||||
state[network].error = error;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.FEATURES](state, { network, features }) {
|
||||
const srv = state[network];
|
||||
if (srv) {
|
||||
srv.features = features;
|
||||
|
||||
if (features.NETWORK && srv.name === network) {
|
||||
srv.name = features.NETWORK;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function connect(config) {
|
||||
return {
|
||||
type: actions.CONNECT,
|
||||
...config,
|
||||
socket: {
|
||||
type: 'connect',
|
||||
data: config
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnect(network) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: actions.DISCONNECT,
|
||||
network,
|
||||
socket: {
|
||||
type: 'quit',
|
||||
data: { network }
|
||||
}
|
||||
});
|
||||
dispatch(updateSelection());
|
||||
};
|
||||
}
|
||||
|
||||
export function reconnect(network, settings) {
|
||||
return {
|
||||
type: actions.RECONNECT,
|
||||
network,
|
||||
settings,
|
||||
socket: {
|
||||
type: 'reconnect',
|
||||
data: {
|
||||
...settings,
|
||||
network
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function whois(user, network) {
|
||||
return {
|
||||
type: actions.WHOIS,
|
||||
user,
|
||||
network,
|
||||
socket: {
|
||||
type: 'whois',
|
||||
data: { user, network }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function away(message, network) {
|
||||
return {
|
||||
type: actions.AWAY,
|
||||
message,
|
||||
network,
|
||||
socket: {
|
||||
type: 'away',
|
||||
data: { message, network }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setNick(nick, network, editing) {
|
||||
nick = nick.trim().replace(' ', '');
|
||||
|
||||
const action = {
|
||||
type: actions.SET_NICK,
|
||||
nick,
|
||||
network,
|
||||
editing
|
||||
};
|
||||
|
||||
if (!editing && nick !== '') {
|
||||
action.socket = {
|
||||
type: 'nick',
|
||||
data: {
|
||||
newNick: nick,
|
||||
network
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
export function isValidNetworkName(name) {
|
||||
return name.trim() !== '';
|
||||
}
|
||||
|
||||
export function setNetworkName(name, network) {
|
||||
const action = {
|
||||
type: actions.SET_NETWORK_NAME,
|
||||
name,
|
||||
network
|
||||
};
|
||||
|
||||
if (isValidNetworkName(name)) {
|
||||
action.socket = {
|
||||
type: 'set_network_name',
|
||||
data: {
|
||||
name,
|
||||
network
|
||||
},
|
||||
debounce: {
|
||||
delay: 500,
|
||||
key: `network_name:${network}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
@ -1,18 +1,18 @@
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { findIndex } from 'utils';
|
||||
import { isDM } from 'utils';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { updateSelection } from './tab';
|
||||
import * as actions from './actions';
|
||||
|
||||
export const getPrivateChats = state => state.privateChats;
|
||||
|
||||
function open(state, server, nick) {
|
||||
if (!state[server]) {
|
||||
state[server] = [];
|
||||
function open(state, network, nick) {
|
||||
if (!state[network]) {
|
||||
state[network] = [];
|
||||
}
|
||||
if (findIndex(state[server], n => n === nick) === -1) {
|
||||
state[server].push(nick);
|
||||
state[server] = sortBy(state[server], v => v.toLowerCase());
|
||||
if (!state[network].includes(nick)) {
|
||||
state[network].push(nick);
|
||||
state[network] = sortBy(state[network], v => v.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,42 +20,66 @@ export default createReducer(
|
||||
{},
|
||||
{
|
||||
[actions.OPEN_PRIVATE_CHAT](state, action) {
|
||||
open(state, action.server, action.nick);
|
||||
open(state, action.network, action.nick);
|
||||
},
|
||||
|
||||
[actions.CLOSE_PRIVATE_CHAT](state, { server, nick }) {
|
||||
const i = findIndex(state[server], n => n === nick);
|
||||
[actions.CLOSE_PRIVATE_CHAT](state, { network, nick }) {
|
||||
const i = state[network]?.findIndex(n => n === nick);
|
||||
if (i !== -1) {
|
||||
state[server].splice(i, 1);
|
||||
state[network].splice(i, 1);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.socket.PM](state, action) {
|
||||
if (action.from.indexOf('.') === -1) {
|
||||
open(state, action.server, action.from);
|
||||
[actions.INIT](state, { openDMs }) {
|
||||
if (openDMs) {
|
||||
openDMs.forEach(({ network, name }) => {
|
||||
if (!state[network]) {
|
||||
state[network] = [];
|
||||
}
|
||||
|
||||
state[network].push(name);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { server }) {
|
||||
delete state[server];
|
||||
[actions.ADD_MESSAGE](state, { message }) {
|
||||
if (isDM(message)) {
|
||||
open(state, message.network, message.from);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, { network }) {
|
||||
delete state[network];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function openPrivateChat(server, nick) {
|
||||
return {
|
||||
type: actions.OPEN_PRIVATE_CHAT,
|
||||
server,
|
||||
nick
|
||||
export function openPrivateChat(network, nick) {
|
||||
return (dispatch, getState) => {
|
||||
if (!getState().privateChats[network]?.includes(nick)) {
|
||||
dispatch({
|
||||
type: actions.OPEN_PRIVATE_CHAT,
|
||||
network,
|
||||
nick,
|
||||
socket: {
|
||||
type: 'open_dm',
|
||||
data: { network, name: nick }
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function closePrivateChat(server, nick) {
|
||||
export function closePrivateChat(network, nick) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: actions.CLOSE_PRIVATE_CHAT,
|
||||
server,
|
||||
nick
|
||||
network,
|
||||
nick,
|
||||
socket: {
|
||||
type: 'close_dm',
|
||||
data: { network, name: nick }
|
||||
}
|
||||
});
|
||||
dispatch(updateSelection());
|
||||
};
|
||||
|
@ -18,15 +18,15 @@ export default createReducer(initialState, {
|
||||
}
|
||||
});
|
||||
|
||||
export function searchMessages(server, channel, phrase) {
|
||||
export function searchMessages(network, channel, phrase) {
|
||||
return {
|
||||
type: actions.SEARCH_MESSAGES,
|
||||
server,
|
||||
network,
|
||||
channel,
|
||||
phrase,
|
||||
socket: {
|
||||
type: 'search',
|
||||
data: { server, channel, phrase }
|
||||
data: { network, channel, phrase }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import get from 'lodash/get';
|
||||
import { getServers } from './servers';
|
||||
import { getNetworks } from './networks';
|
||||
import { getSelectedTab } from './tab';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const getSelectedTabTitle = createSelector(
|
||||
getSelectedTab,
|
||||
getServers,
|
||||
(tab, servers) => tab.name || get(servers, [tab.server, 'name'])
|
||||
getNetworks,
|
||||
(tab, networks) => tab.name || get(networks, [tab.network, 'name'])
|
||||
);
|
||||
|
@ -1,210 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import get from 'lodash/get';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { getSelectedTab, updateSelection } from './tab';
|
||||
import * as actions from './actions';
|
||||
|
||||
export const 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;
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import assign from 'lodash/assign';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import * as actions from './actions';
|
||||
|
||||
@ -41,10 +40,14 @@ export default createReducer(
|
||||
|
||||
[actions.SETTINGS_SET](state, { key, value, settings }) {
|
||||
if (settings) {
|
||||
assign(state, settings);
|
||||
Object.assign(state, settings);
|
||||
} else {
|
||||
state[key] = value;
|
||||
}
|
||||
},
|
||||
|
||||
[actions.INIT](state, { settings }) {
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -80,7 +83,7 @@ export function setCert(fileName, cert) {
|
||||
return {
|
||||
type: actions.SET_CERT,
|
||||
fileName,
|
||||
cert: cert
|
||||
cert
|
||||
};
|
||||
}
|
||||
|
||||
@ -88,7 +91,7 @@ export function setKey(fileName, key) {
|
||||
return {
|
||||
type: actions.SET_KEY,
|
||||
fileName,
|
||||
key: key
|
||||
key
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,9 @@
|
||||
import get from 'lodash/get';
|
||||
import Cookie from 'js-cookie';
|
||||
import createReducer from 'utils/createReducer';
|
||||
import { push, replace, LOCATION_CHANGED } from 'utils/router';
|
||||
import * as actions from './actions';
|
||||
import { find } from '../utils';
|
||||
|
||||
const initialState = {
|
||||
selected: {},
|
||||
@ -9,7 +12,7 @@ const initialState = {
|
||||
|
||||
function selectTab(state, action) {
|
||||
state.selected = {
|
||||
server: action.server,
|
||||
network: action.network,
|
||||
name: action.name
|
||||
};
|
||||
state.history.push(state.selected);
|
||||
@ -20,20 +23,31 @@ export const getSelectedTab = state => state.tab.selected;
|
||||
export default createReducer(initialState, {
|
||||
[actions.SELECT_TAB]: selectTab,
|
||||
|
||||
[actions.JOIN](state, { network, channels, selectFirst }) {
|
||||
if (selectFirst) {
|
||||
state.selected = {
|
||||
network,
|
||||
name: channels[0]
|
||||
};
|
||||
state.history.push(state.selected);
|
||||
}
|
||||
},
|
||||
|
||||
[actions.PART](state, action) {
|
||||
state.history = state.history.filter(
|
||||
tab => !(tab.server === action.server && tab.name === action.channels[0])
|
||||
tab =>
|
||||
!(tab.network === action.network && 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)
|
||||
tab => !(tab.network === action.network && tab.name === action.nick)
|
||||
);
|
||||
},
|
||||
|
||||
[actions.DISCONNECT](state, action) {
|
||||
state.history = state.history.filter(tab => tab.server !== action.server);
|
||||
state.history = state.history.filter(tab => tab.network !== action.network);
|
||||
},
|
||||
|
||||
[LOCATION_CHANGED](state, action) {
|
||||
@ -46,39 +60,74 @@ export default createReducer(initialState, {
|
||||
}
|
||||
});
|
||||
|
||||
export function select(server, name, doReplace) {
|
||||
export function select(network, name, doReplace) {
|
||||
const navigate = doReplace ? replace : push;
|
||||
if (name) {
|
||||
return navigate(`/${server}/${encodeURIComponent(name)}`);
|
||||
return navigate(`/${network}/${encodeURIComponent(name)}`);
|
||||
}
|
||||
return navigate(`/${server}`);
|
||||
return navigate(`/${network}`);
|
||||
}
|
||||
|
||||
export function updateSelection() {
|
||||
export function tabExists(
|
||||
{ network, name },
|
||||
{ networks, channels, privateChats }
|
||||
) {
|
||||
return (
|
||||
(name && get(channels, [network, name])) ||
|
||||
(!name && network && networks[network]) ||
|
||||
(name && find(privateChats[network], nick => nick === name))
|
||||
);
|
||||
}
|
||||
|
||||
function parseTabCookie() {
|
||||
const cookie = Cookie.get('tab');
|
||||
if (cookie) {
|
||||
const [network, name = null] = cookie.split(/;(.+)/);
|
||||
return { network, name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function updateSelection(tryCookie) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const { history } = state.tab;
|
||||
const { servers } = state;
|
||||
const { server } = state.tab.selected;
|
||||
const serverAddrs = Object.keys(servers);
|
||||
|
||||
if (serverAddrs.length === 0) {
|
||||
if (tabExists(state.tab.selected, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tryCookie) {
|
||||
const tab = parseTabCookie();
|
||||
if (tab && tabExists(tab, state)) {
|
||||
return dispatch(select(tab.network, tab.name, true));
|
||||
}
|
||||
}
|
||||
|
||||
const { networks } = state;
|
||||
const { history } = state.tab;
|
||||
const { network } = state.tab.selected;
|
||||
const networkAddrs = Object.keys(networks);
|
||||
|
||||
if (networkAddrs.length === 0) {
|
||||
dispatch(replace('/connect'));
|
||||
} else if (history.length > 0) {
|
||||
} else if (
|
||||
history.length > 0 &&
|
||||
tabExists(history[history.length - 1], state)
|
||||
) {
|
||||
const tab = history[history.length - 1];
|
||||
dispatch(select(tab.server, tab.name, true));
|
||||
} else if (servers[server]) {
|
||||
dispatch(select(server, null, true));
|
||||
dispatch(select(tab.network, tab.name, true));
|
||||
} else if (networks[network]) {
|
||||
dispatch(select(network, null, true));
|
||||
} else {
|
||||
dispatch(select(serverAddrs.sort()[0], null, true));
|
||||
dispatch(select(networkAddrs.sort()[0], null, true));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setSelectedTab(server, name = null) {
|
||||
export function setSelectedTab(network, name = null) {
|
||||
return {
|
||||
type: actions.SELECT_TAB,
|
||||
server,
|
||||
network,
|
||||
name
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,17 @@
|
||||
workbox.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
import { skipWaiting, clientsClaim } from 'workbox-core';
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||
import { NavigationRoute, registerRoute } from 'workbox-routing';
|
||||
|
||||
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
|
||||
skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST, {
|
||||
ignoreUrlParametersMatching: [/.*/]
|
||||
});
|
||||
|
||||
const handler = createHandlerBoundToURL('/');
|
||||
registerRoute(
|
||||
new NavigationRoute(handler, {
|
||||
denylist: [new RegExp('/downloads/')]
|
||||
})
|
||||
);
|
||||
|
@ -26,7 +26,7 @@ export default class Socket {
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.connected = true;
|
||||
this.emit('_connected', true);
|
||||
this.emit('connected', { connected: true });
|
||||
clearTimeout(this.timeoutConnect);
|
||||
this.backoff.reset();
|
||||
this.setTimeoutPing();
|
||||
@ -35,7 +35,7 @@ export default class Socket {
|
||||
this.ws.onclose = () => {
|
||||
if (this.connected) {
|
||||
this.connected = false;
|
||||
this.emit('_connected', false);
|
||||
this.emit('connected', { connected: false });
|
||||
}
|
||||
clearTimeout(this.timeoutConnect);
|
||||
clearTimeout(this.timeoutPing);
|
||||
|
@ -1,9 +1,19 @@
|
||||
import React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
import { isChannel, isValidNick, isValidChannel, isValidUsername } from '..';
|
||||
import {
|
||||
trimPrefixChar,
|
||||
isChannel,
|
||||
isValidNick,
|
||||
isValidChannel,
|
||||
isValidUsername
|
||||
} from '..';
|
||||
import linkify from '../linkify';
|
||||
|
||||
const render = el => TestRenderer.create(el).toJSON();
|
||||
describe('trimPrefixChar()', () => {
|
||||
it('trims prefix characters', () => {
|
||||
expect(trimPrefixChar('##chan', '#')).toBe('chan');
|
||||
expect(trimPrefixChar('#chan', '#')).toBe('chan');
|
||||
expect(trimPrefixChar('chan', '#')).toBe('chan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChannel()', () => {
|
||||
it('it handles strings', () => {
|
||||
@ -81,21 +91,31 @@ describe('isValidUsername()', () => {
|
||||
|
||||
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>
|
||||
);
|
||||
const linkTo = href => ({
|
||||
type: 'link',
|
||||
url: proto(href),
|
||||
text: href
|
||||
});
|
||||
const buildText = arr => {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (typeof arr[i] === 'string') {
|
||||
arr[i] = {
|
||||
type: 'text',
|
||||
text: arr[i]
|
||||
};
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
it('returns the arg when no matches are found', () =>
|
||||
[null, undefined, 10, false, true, 'just some text', ''].forEach(input =>
|
||||
expect(linkify(input)).toBe(input)
|
||||
it('returns a text block when no matches are found', () =>
|
||||
['just some text', ''].forEach(input =>
|
||||
expect(linkify(input)).toStrictEqual([{ type: 'text', text: input }])
|
||||
));
|
||||
|
||||
it('linkifies text', () =>
|
||||
Object.entries({
|
||||
'google.com': linkTo('google.com'),
|
||||
'google.com': [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': [
|
||||
@ -115,6 +135,6 @@ describe('linkify()', () => {
|
||||
'google.com ': [linkTo('google.com'), ' '],
|
||||
'/google.com?': ['/', linkTo('google.com'), '?']
|
||||
}).forEach(([input, expected]) =>
|
||||
expect(render(linkify(input))).toEqual(expected)
|
||||
expect(linkify(input)).toEqual(buildText(expected))
|
||||
));
|
||||
});
|
||||
|
@ -1,25 +1,5 @@
|
||||
/* 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;
|
||||
};
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
|
||||
const colors = [];
|
||||
|
||||
|
356
client/js/utils/colorify.js
Normal file
356
client/js/utils/colorify.js
Normal file
@ -0,0 +1,356 @@
|
||||
export const formatChars = {
|
||||
bold: 0x02,
|
||||
italic: 0x1d,
|
||||
underline: 0x1f,
|
||||
strikethrough: 0x1e,
|
||||
color: 0x03,
|
||||
reverseColor: 0x16,
|
||||
reset: 0x0f
|
||||
};
|
||||
|
||||
export const colors = {
|
||||
0: 'white',
|
||||
1: 'black',
|
||||
2: 'blue',
|
||||
3: 'green',
|
||||
4: 'red',
|
||||
5: 'brown',
|
||||
6: 'magenta',
|
||||
7: 'orange',
|
||||
8: 'yellow',
|
||||
9: 'lightgreen',
|
||||
10: 'cyan',
|
||||
11: 'lightcyan',
|
||||
12: 'lightblue',
|
||||
13: 'pink',
|
||||
14: 'gray',
|
||||
15: 'lightgray',
|
||||
16: '#470000',
|
||||
17: '#472100',
|
||||
18: '#474700',
|
||||
19: '#324700',
|
||||
20: '#004700',
|
||||
21: '#00472c',
|
||||
22: '#004747',
|
||||
23: '#002747',
|
||||
24: '#000047',
|
||||
25: '#2e0047',
|
||||
26: '#470047',
|
||||
27: '#47002a',
|
||||
28: '#740000',
|
||||
29: '#743a00',
|
||||
30: '#747400',
|
||||
31: '#517400',
|
||||
32: '#007400',
|
||||
33: '#007449',
|
||||
34: '#007474',
|
||||
35: '#004074',
|
||||
36: '#000074',
|
||||
37: '#4b0074',
|
||||
38: '#740074',
|
||||
39: '#740045',
|
||||
40: '#b50000',
|
||||
41: '#b56300',
|
||||
42: '#b5b500',
|
||||
43: '#7db500',
|
||||
44: '#00b500',
|
||||
45: '#00b571',
|
||||
46: '#00b5b5',
|
||||
47: '#0063b5',
|
||||
48: '#0000b5',
|
||||
49: '#7500b5',
|
||||
50: '#b500b5',
|
||||
51: '#b5006b',
|
||||
52: '#ff0000',
|
||||
53: '#ff8c00',
|
||||
54: '#ffff00',
|
||||
55: '#b2ff00',
|
||||
56: '#00ff00',
|
||||
57: '#00ffa0',
|
||||
58: '#00ffff',
|
||||
59: '#008cff',
|
||||
60: '#0000ff',
|
||||
61: '#a500ff',
|
||||
62: '#ff00ff',
|
||||
63: '#ff0098',
|
||||
64: '#ff5959',
|
||||
65: '#ffb459',
|
||||
66: '#ffff71',
|
||||
67: '#cfff60',
|
||||
68: '#6fff6f',
|
||||
69: '#65ffc9',
|
||||
70: '#6dffff',
|
||||
71: '#59b4ff',
|
||||
72: '#5959ff',
|
||||
73: '#c459ff',
|
||||
74: '#ff66ff',
|
||||
75: '#ff59bc',
|
||||
76: '#ff9c9c',
|
||||
77: '#ffd39c',
|
||||
78: '#ffff9c',
|
||||
79: '#e2ff9c',
|
||||
80: '#9cff9c',
|
||||
81: '#9cffdb',
|
||||
82: '#9cffff',
|
||||
83: '#9cd3ff',
|
||||
84: '#9c9cff',
|
||||
85: '#dc9cff',
|
||||
86: '#ff9cff',
|
||||
87: '#ff94d3',
|
||||
88: '#000000',
|
||||
89: '#131313',
|
||||
90: '#282828',
|
||||
91: '#363636',
|
||||
92: '#4d4d4d',
|
||||
93: '#656565',
|
||||
94: '#818181',
|
||||
95: '#9f9f9f',
|
||||
96: '#bcbcbc',
|
||||
97: '#e2e2e2',
|
||||
98: '#ffffff'
|
||||
};
|
||||
|
||||
function tokenize(str) {
|
||||
const tokens = [];
|
||||
|
||||
let colorBuffer = '';
|
||||
let color = false;
|
||||
let background = false;
|
||||
let colorToken;
|
||||
|
||||
let start = 0;
|
||||
let end = 0;
|
||||
|
||||
const pushText = () => {
|
||||
if (end > start) {
|
||||
tokens.push({
|
||||
type: 'text',
|
||||
content: str.slice(start, end)
|
||||
});
|
||||
start = end;
|
||||
}
|
||||
};
|
||||
|
||||
const pushToken = token => {
|
||||
pushText();
|
||||
tokens.push(token);
|
||||
};
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const charCode = str.charCodeAt(i);
|
||||
|
||||
if (color) {
|
||||
if (charCode >= 48 && charCode <= 57 && colorBuffer.length < 2) {
|
||||
colorBuffer += str[i];
|
||||
} else if (charCode === 44 && !background) {
|
||||
colorToken.color = colors[parseInt(colorBuffer, 10)];
|
||||
colorBuffer = '';
|
||||
background = true;
|
||||
} else {
|
||||
if (background) {
|
||||
if (colorBuffer.length > 0) {
|
||||
colorToken.background = colors[parseInt(colorBuffer, 10)];
|
||||
} else {
|
||||
// Trailing comma
|
||||
start--;
|
||||
}
|
||||
} else {
|
||||
colorToken.color = colors[parseInt(colorBuffer, 10)];
|
||||
}
|
||||
|
||||
start--;
|
||||
colorBuffer = '';
|
||||
color = false;
|
||||
tokens.push(colorToken);
|
||||
}
|
||||
} else {
|
||||
switch (charCode) {
|
||||
case formatChars.bold:
|
||||
pushToken({
|
||||
type: 'bold'
|
||||
});
|
||||
break;
|
||||
|
||||
case formatChars.italic:
|
||||
pushToken({
|
||||
type: 'italic'
|
||||
});
|
||||
break;
|
||||
|
||||
case formatChars.underline:
|
||||
pushToken({
|
||||
type: 'underline'
|
||||
});
|
||||
break;
|
||||
|
||||
case formatChars.strikethrough:
|
||||
pushToken({
|
||||
type: 'strikethrough'
|
||||
});
|
||||
break;
|
||||
|
||||
case formatChars.color:
|
||||
pushText();
|
||||
|
||||
colorToken = {
|
||||
type: 'color'
|
||||
};
|
||||
color = true;
|
||||
background = false;
|
||||
break;
|
||||
|
||||
case formatChars.reverseColor:
|
||||
pushToken({
|
||||
type: 'reverse'
|
||||
});
|
||||
break;
|
||||
|
||||
case formatChars.reset:
|
||||
pushToken({
|
||||
type: 'reset'
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
start--;
|
||||
}
|
||||
}
|
||||
|
||||
start++;
|
||||
end++;
|
||||
}
|
||||
|
||||
if (start === 0) {
|
||||
return str;
|
||||
}
|
||||
|
||||
pushText();
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function colorifyString(str, state = {}) {
|
||||
const tokens = tokenize(str);
|
||||
|
||||
if (tokens === str) {
|
||||
return [tokens, state];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
let style = state.style || {};
|
||||
let reverse = state.reverse || false;
|
||||
|
||||
const toggle = (prop, value, multiple) => {
|
||||
if (style[prop]) {
|
||||
if (multiple) {
|
||||
const props = style[prop].split(' ');
|
||||
const i = props.indexOf(value);
|
||||
if (i !== -1) {
|
||||
props.splice(i, 1);
|
||||
} else {
|
||||
props.push(value);
|
||||
}
|
||||
style[prop] = props.join(' ');
|
||||
} else {
|
||||
delete style[prop];
|
||||
}
|
||||
} else {
|
||||
style[prop] = value;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
switch (token.type) {
|
||||
case 'bold':
|
||||
toggle('fontWeight', 700);
|
||||
break;
|
||||
|
||||
case 'italic':
|
||||
toggle('fontStyle', 'italic');
|
||||
break;
|
||||
|
||||
case 'underline':
|
||||
toggle('textDecoration', 'underline', true);
|
||||
break;
|
||||
|
||||
case 'strikethrough':
|
||||
toggle('textDecoration', 'line-through', true);
|
||||
break;
|
||||
|
||||
case 'color':
|
||||
if (!token.color) {
|
||||
delete style.color;
|
||||
delete style.background;
|
||||
} else if (reverse) {
|
||||
style.color = token.background;
|
||||
style.background = token.color;
|
||||
} else {
|
||||
style.color = token.color;
|
||||
style.background = token.background;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reverse':
|
||||
reverse = !reverse;
|
||||
if (style.color) {
|
||||
const bg = style.background;
|
||||
style.background = style.color;
|
||||
style.color = bg;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reset':
|
||||
style = {};
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (Object.keys(style).length > 0) {
|
||||
result.push({
|
||||
type: 'format',
|
||||
style,
|
||||
text: token.content
|
||||
});
|
||||
style = { ...style };
|
||||
} else {
|
||||
result.push({
|
||||
type: 'text',
|
||||
text: token.content
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return [result, { style, reverse }];
|
||||
}
|
||||
|
||||
export default function colorify(blocks) {
|
||||
if (!blocks) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
let colored;
|
||||
let state;
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
|
||||
if (block.type === 'text') {
|
||||
[colored, state] = colorifyString(block.text, state);
|
||||
if (colored !== block.text) {
|
||||
result.push(...colored);
|
||||
} else {
|
||||
result.push(block);
|
||||
}
|
||||
} else {
|
||||
result.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@ -3,11 +3,6 @@ import { connect } from 'react-redux';
|
||||
const strictEqual = (a, b) => a === b;
|
||||
|
||||
export default (mapState, mapDispatch) =>
|
||||
connect(
|
||||
mapState,
|
||||
mapDispatch,
|
||||
null,
|
||||
{
|
||||
areStatePropsEqual: strictEqual
|
||||
}
|
||||
);
|
||||
connect(mapState, mapDispatch, null, {
|
||||
areStatePropsEqual: strictEqual
|
||||
});
|
||||
|
@ -3,17 +3,6 @@ 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') {
|
||||
@ -22,17 +11,17 @@ export function isChannel(name) {
|
||||
return typeof name === 'string' && name[0] === '#';
|
||||
}
|
||||
|
||||
export function stringifyTab(server, name) {
|
||||
if (typeof server === 'object') {
|
||||
if (server.name) {
|
||||
return `${server.server};${server.name}`;
|
||||
export function stringifyTab(network, name) {
|
||||
if (typeof network === 'object') {
|
||||
if (network.name) {
|
||||
return `${network.network};${network.name}`;
|
||||
}
|
||||
return server.server;
|
||||
return network.network;
|
||||
}
|
||||
if (name) {
|
||||
return `${server};${name}`;
|
||||
return `${network};${name}`;
|
||||
}
|
||||
return server;
|
||||
return network;
|
||||
}
|
||||
|
||||
function isString(s, maxLength) {
|
||||
@ -45,6 +34,26 @@ function isString(s, maxLength) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isDM({ from, to }) {
|
||||
return !to && from?.indexOf('.') === -1 && !isChannel(from);
|
||||
}
|
||||
|
||||
export function trimPrefixChar(str, char) {
|
||||
if (!isString(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
let start = 0;
|
||||
while (str[start] === char) {
|
||||
start++;
|
||||
}
|
||||
|
||||
if (start > 0) {
|
||||
return str.slice(start);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// RFC 2812
|
||||
// nickname = ( letter / special ) *( letter / digit / special / "-" )
|
||||
// letter = A-Z / a-z
|
||||
@ -124,6 +133,10 @@ export function isValidUsername(username) {
|
||||
}
|
||||
|
||||
export function isInt(i, min, max) {
|
||||
if (typeof i === 'string') {
|
||||
i = parseInt(i, 10);
|
||||
}
|
||||
|
||||
if (i < min || i > max || Math.floor(i) !== i) {
|
||||
return false;
|
||||
}
|
||||
@ -137,6 +150,16 @@ export function timestamp(date = new Date()) {
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat(window.navigator.language);
|
||||
export const formatDate = dateFmt.format;
|
||||
|
||||
export function unix(date) {
|
||||
if (date) {
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
@ -167,7 +190,7 @@ export function measureScrollBarWidth() {
|
||||
}
|
||||
|
||||
export function findIndex(arr, pred) {
|
||||
if (!arr) {
|
||||
if (!Array.isArray(arr) || typeof pred !== 'function') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@ -187,3 +210,17 @@ export function find(arr, pred) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function count(arr, pred) {
|
||||
if (!Array.isArray(arr) || typeof pred !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let c = 0;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (pred(arr[i])) {
|
||||
c++;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
@ -1,16 +1,44 @@
|
||||
import Autolinker from 'autolinker';
|
||||
import React from 'react';
|
||||
|
||||
const autolinker = new Autolinker({
|
||||
stripPrefix: false,
|
||||
stripTrailingSlash: false
|
||||
});
|
||||
|
||||
function pushText(arr, text) {
|
||||
const last = arr[arr.length - 1];
|
||||
if (last?.type === 'text') {
|
||||
last.text += text;
|
||||
} else {
|
||||
arr.push({
|
||||
type: 'text',
|
||||
text
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pushLink(arr, url, text) {
|
||||
arr.push({
|
||||
type: 'link',
|
||||
url,
|
||||
text
|
||||
});
|
||||
}
|
||||
|
||||
export default function linkify(text) {
|
||||
if (typeof text !== 'string') {
|
||||
return text;
|
||||
}
|
||||
|
||||
let matches = autolinker.parseText(text);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return text;
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
@ -22,46 +50,27 @@ export default function linkify(text) {
|
||||
|
||||
if (match.getType() === 'url') {
|
||||
if (match.offset > pos) {
|
||||
if (typeof result[result.length - 1] === 'string') {
|
||||
result[result.length - 1] += text.slice(pos, match.offset);
|
||||
} else {
|
||||
result.push(text.slice(pos, match.offset));
|
||||
}
|
||||
pushText(result, text.slice(pos, match.offset));
|
||||
}
|
||||
|
||||
result.push(
|
||||
<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
|
||||
);
|
||||
pushLink(result, match.getAnchorHref(), match.matchedText);
|
||||
} else {
|
||||
result.push(text.slice(pos, match.offset + match.matchedText.length));
|
||||
pushText(
|
||||
result,
|
||||
text.slice(pos, match.offset + match.matchedText.length)
|
||||
);
|
||||
}
|
||||
|
||||
pos = match.offset + match.matchedText.length;
|
||||
}
|
||||
|
||||
if (pos < text.length) {
|
||||
if (typeof result[result.length - 1] === 'string') {
|
||||
result[result.length - 1] += text.slice(pos);
|
||||
if (result[result.length - 1]?.type === 'text') {
|
||||
result[result.length - 1].text += text.slice(pos);
|
||||
} else {
|
||||
result.push(text.slice(pos));
|
||||
pushText(result, text.slice(pos));
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 1) {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
@ -2,20 +2,27 @@ const lineHeight = 24;
|
||||
const userListWidth = 200;
|
||||
const smallScreen = 600;
|
||||
|
||||
export function findBreakpoints(text) {
|
||||
export function findBreakpoints(blocks) {
|
||||
const breakpoints = [];
|
||||
let length = 0;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charAt(i);
|
||||
for (let j = 0; j < blocks.length; j++) {
|
||||
const {text} = blocks[j];
|
||||
|
||||
if (char === ' ') {
|
||||
breakpoints.push({ end: i, next: i + 1 });
|
||||
} else if (char === '-' && i !== text.length - 1) {
|
||||
breakpoints.push({ end: i + 1, next: i + 1 });
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charAt(i);
|
||||
|
||||
if (char === ' ') {
|
||||
breakpoints.push({ end: length + i, next: length + i + 1 });
|
||||
} else if (i !== text.length - 1 && (char === '-' || char === '?')) {
|
||||
breakpoints.push({ end: length + i + 1, next: length + i + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
length += text.length;
|
||||
}
|
||||
|
||||
return breakpoints;
|
||||
return [breakpoints, length];
|
||||
}
|
||||
|
||||
export function messageHeight(
|
||||
|
@ -1,18 +1,26 @@
|
||||
import createHistory from 'history/createBrowserHistory';
|
||||
import history from 'history/browser';
|
||||
import UrlPattern from 'url-pattern';
|
||||
|
||||
const history = createHistory();
|
||||
|
||||
export const LOCATION_CHANGED = 'ROUTER_LOCATION_CHANGED';
|
||||
export const PUSH = 'ROUTER_PUSH';
|
||||
export const REPLACE = 'ROUTER_REPLACE';
|
||||
|
||||
export function locationChanged(route, params, location) {
|
||||
Object.keys(params).forEach(key => {
|
||||
params[key] = decodeURIComponent(params[key]);
|
||||
});
|
||||
|
||||
const query = {};
|
||||
new URLSearchParams(location.search).forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
type: LOCATION_CHANGED,
|
||||
route,
|
||||
params,
|
||||
location
|
||||
query,
|
||||
path: decodeURIComponent(location.pathname)
|
||||
};
|
||||
}
|
||||
|
||||
@ -30,13 +38,9 @@ export function replace(path) {
|
||||
};
|
||||
}
|
||||
|
||||
export function routeReducer(state = {}, action) {
|
||||
if (action.type === LOCATION_CHANGED) {
|
||||
return {
|
||||
route: action.route,
|
||||
params: action.params,
|
||||
location: action.location
|
||||
};
|
||||
export function routeReducer(state = {}, { type, ...action }) {
|
||||
if (type === LOCATION_CHANGED) {
|
||||
return action;
|
||||
}
|
||||
|
||||
return state;
|
||||
@ -46,7 +50,7 @@ export function routeMiddleware() {
|
||||
return next => action => {
|
||||
switch (action.type) {
|
||||
case PUSH:
|
||||
history.push(action.path);
|
||||
history.push(`${action.path}`);
|
||||
break;
|
||||
case REPLACE:
|
||||
history.replace(action.path);
|
||||
@ -57,24 +61,13 @@ export function routeMiddleware() {
|
||||
};
|
||||
}
|
||||
|
||||
function decode(location) {
|
||||
location.pathname = decodeURIComponent(location.pathname);
|
||||
return location;
|
||||
}
|
||||
|
||||
function match(routes, location) {
|
||||
let params;
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
params = routes[i].pattern.match(location.pathname);
|
||||
const params = routes[i].pattern.match(location.pathname);
|
||||
if (params !== null) {
|
||||
const keys = Object.keys(params);
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
params[keys[j]] = decodeURIComponent(params[keys[j]]);
|
||||
}
|
||||
return locationChanged(routes[i].name, params, decode(location));
|
||||
return locationChanged(routes[i].name, params, location);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function initRouter(routes, store) {
|
||||
@ -93,16 +86,11 @@ export default function initRouter(routes, store) {
|
||||
let matched = match(patterns, history.location);
|
||||
if (matched) {
|
||||
store.dispatch(matched);
|
||||
} else {
|
||||
matched = { location: {} };
|
||||
}
|
||||
|
||||
history.listen(location => {
|
||||
history.listen(({ location }) => {
|
||||
const nextMatch = match(patterns, location);
|
||||
if (
|
||||
nextMatch &&
|
||||
nextMatch.location.pathname !== matched.location.pathname
|
||||
) {
|
||||
if (nextMatch && nextMatch.path !== matched?.path) {
|
||||
matched = nextMatch;
|
||||
store.dispatch(matched);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
let width, height;
|
||||
let width;
|
||||
let height;
|
||||
const listeners = [];
|
||||
|
||||
function update() {
|
||||
|
6
client/jsconfig.json
Normal file
6
client/jsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./js"
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
@ -5,79 +5,88 @@
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"browserslist": [
|
||||
"Edge >= 16",
|
||||
"Edge >= 79",
|
||||
"Firefox >= 60",
|
||||
"Chrome >= 61",
|
||||
"Safari >= 10.1",
|
||||
"iOS >= 10.3"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@babel/core": "^7.10.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.10.1",
|
||||
"@babel/plugin-proposal-export-default-from": "^7.10.1",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.10.1",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.10.1",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.10.1",
|
||||
"@babel/preset-env": "^7.10.2",
|
||||
"@babel/preset-react": "^7.10.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.0.1",
|
||||
"babel-loader": "^8.1.0",
|
||||
"brotli": "^1.3.1",
|
||||
"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",
|
||||
"canvas": "^2.6.1",
|
||||
"copy-webpack-plugin": "^6.0.2",
|
||||
"cross-env": "^7.0.2",
|
||||
"css-loader": "^3.5.3",
|
||||
"cssnano": "^4.1.10",
|
||||
"del": "^5.1.0",
|
||||
"eslint": "^7.2.0",
|
||||
"eslint-config-airbnb": "^18.2.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-import-resolver-webpack": "^0.12.2",
|
||||
"eslint-loader": "^4.0.2",
|
||||
"eslint-plugin-babel": "^5.3.0",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.3.0",
|
||||
"eslint-plugin-react": "^7.20.0",
|
||||
"eslint-plugin-react-hooks": "^4.0.4",
|
||||
"express": "^4.17.1",
|
||||
"express-http-proxy": "^1.6.0",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-util": "^3.0.8",
|
||||
"jest": "^23.6.0",
|
||||
"mini-css-extract-plugin": "^0.4.4",
|
||||
"postcss-flexbugs-fixes": "^4.1.0",
|
||||
"jest": "^26.0.1",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"postcss-flexbugs-fixes": "^4.2.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.4.0",
|
||||
"prettier": "1.15.2",
|
||||
"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",
|
||||
"webpack-plugin-hash-output": "^3.1.0",
|
||||
"workbox-webpack-plugin": "^3.6.3"
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "2.0.5",
|
||||
"react-test-renderer": "16.13.1",
|
||||
"style-loader": "^1.2.1",
|
||||
"terser-webpack-plugin": "^3.0.6",
|
||||
"through2": "^3.0.1",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-dev-middleware": "^3.7.2",
|
||||
"webpack-hot-middleware": "^2.25.0",
|
||||
"webpack-plugin-hash-output": "^3.2.1",
|
||||
"workbox-webpack-plugin": "^5.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"autolinker": "^1.7.1",
|
||||
"@sindresorhus/fnv1a": "^2.0.1",
|
||||
"autolinker": "^3.14.1",
|
||||
"backo": "^1.1.0",
|
||||
"classnames": "^2.2.6",
|
||||
"fontfaceobserver": "^2.0.9",
|
||||
"formik": "^1.3.1",
|
||||
"history": "4.5.1",
|
||||
"hsluv": "^0.0.3",
|
||||
"immer": "^1.7.3",
|
||||
"js-cookie": "^2.1.4",
|
||||
"lodash": "^4.17.11",
|
||||
"react": "^16.7.0-alpha.0",
|
||||
"react-dom": "^16.7.0-alpha.0",
|
||||
"react-hot-loader": "^4.4.0",
|
||||
"react-redux": "^6.0.0-beta.2",
|
||||
"formik": "^2.1.4",
|
||||
"history": "^5.0.0-beta.8",
|
||||
"hsluv": "^0.1.0",
|
||||
"immer": "^7.0.1",
|
||||
"js-cookie": "^2.2.1",
|
||||
"lodash": "^4.17.15",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react-hot-loader": "^4.12.21",
|
||||
"react-icons": "^3.7.0",
|
||||
"react-modal": "^3.11.2",
|
||||
"react-redux": "^7.2.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-window": "^1.2.2",
|
||||
"redux": "^4.0.1",
|
||||
"react-window": "^1.8.5",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"url-pattern": "^1.0.3"
|
||||
"url-pattern": "^1.0.3",
|
||||
"workbox-core": "^5.1.3",
|
||||
"workbox-precaching": "^5.1.3",
|
||||
"workbox-routing": "^5.1.3"
|
||||
},
|
||||
"scripts": {
|
||||
"prettier": "prettier --write {.*,*.js,css/*.css,**/*.test.js}",
|
||||
@ -85,10 +94,9 @@
|
||||
"test": "jest",
|
||||
"test:verbose": "jest --verbose",
|
||||
"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:install": "cross-env GO111MODULE=off go get -u github.com/andyleap/gencode github.com/mailru/easyjson/...",
|
||||
"gen:binary": "gencode go -package storage -schema ../storage/storage.schema -unsafe",
|
||||
"gen:json": "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"
|
||||
"gen:json": "cross-env GO111MODULE=off easyjson -all -lower_camel_case -omit_empty ../server/json.go ../server/index_data.go ../storage/network.go && cross-env GO111MODULE=off easyjson -lower_camel_case -omit_empty ../storage/user.go"
|
||||
},
|
||||
"jest": {
|
||||
"moduleNameMapper": {
|
||||
@ -96,6 +104,9 @@
|
||||
"^containers(.*)$": "<rootDir>/js/containers$1",
|
||||
"^state(.*)$": "<rootDir>/js/state$1",
|
||||
"^utils(.*)$": "<rootDir>/js/utils$1"
|
||||
}
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/?!(history)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Binary file not shown.
@ -17,5 +17,5 @@
|
||||
"background_color": "#f0f0f0",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"theme_color": "#f0f0f0"
|
||||
"theme_color": "#222"
|
||||
}
|
||||
|
@ -4,9 +4,12 @@ var postcssPresetEnv = require('postcss-preset-env');
|
||||
|
||||
module.exports = {
|
||||
mode: 'development',
|
||||
entry: ['webpack-hot-middleware/client', './js/index'],
|
||||
entry: {
|
||||
main: ['webpack-hot-middleware/client', './js/index'],
|
||||
boot: './js/boot'
|
||||
},
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
filename: '[name].js',
|
||||
publicPath: '/'
|
||||
},
|
||||
resolve: {
|
||||
@ -28,7 +31,11 @@ module.exports = {
|
||||
fix: true
|
||||
}
|
||||
},
|
||||
{ test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ },
|
||||
{
|
||||
test: /\.js$/,
|
||||
use: ['babel-loader', 'react-hot-loader/webpack'],
|
||||
exclude: /node_modules/
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
|
@ -5,6 +5,7 @@ var cssnano = require('cssnano');
|
||||
var TerserPlugin = require('terser-webpack-plugin');
|
||||
var { InjectManifest } = require('workbox-webpack-plugin');
|
||||
var HashOutputPlugin = require('webpack-plugin-hash-output');
|
||||
var CopyPlugin = require('copy-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
@ -79,28 +80,27 @@ module.exports = {
|
||||
filename: '[name].[contenthash].css',
|
||||
chunkFilename: '[name].[contenthash].css'
|
||||
}),
|
||||
new HashOutputPlugin(),
|
||||
new CopyPlugin({
|
||||
patterns: ['public']
|
||||
}),
|
||||
new InjectManifest({
|
||||
swSrc: './js/sw.js',
|
||||
importWorkboxFrom: 'local',
|
||||
globDirectory: './public',
|
||||
globPatterns: ['*', 'font/*.woff2'],
|
||||
exclude: [
|
||||
/\.map$/,
|
||||
/^manifest.*\.js(?:on)?$/,
|
||||
/^boot.*\.js$/,
|
||||
/^runtime.*\.js$/
|
||||
]
|
||||
}),
|
||||
new HashOutputPlugin()
|
||||
additionalManifestEntries: [
|
||||
{
|
||||
url: '/',
|
||||
revision: '__INDEX_REVISON__'
|
||||
}
|
||||
],
|
||||
exclude: [/\.map$/, /^boot.*\.js$/, /^runtime.*\.js$/, /\.txt$/]
|
||||
})
|
||||
],
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
safari10: true
|
||||
},
|
||||
cache: true,
|
||||
parallel: true
|
||||
}
|
||||
})
|
||||
],
|
||||
splitChunks: {
|
||||
|
11248
client/yarn.lock
11248
client/yarn.lock
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user