194 Commits
v0.3 ... v0.5.1

Author SHA1 Message Date
2c1a6f2c44 Discard git changes when doing a release on travis 2018-11-17 12:34:12 +01:00
96f18117bd v0.5.1 2018-11-17 12:22:28 +01:00
90785aba20 Create sessions on boot requests instead of websocket handshakes 2018-11-17 11:52:36 +01:00
da87dccb53 Update react-hot-loader to 4.4.0, turn on pureSFC 2018-11-17 11:12:30 +01:00
b54cb42426 Setup goreleaser 2018-11-17 10:32:02 +01:00
4dcf674c4b Add robots.txt, clean up index template 2018-11-14 10:11:15 +01:00
764475b2a5 Turn on terserOptions.safari10 2018-11-14 09:24:40 +01:00
d867ca8477 Add worker-src csp directive 2018-11-14 08:34:35 +01:00
a783a87d04 Set overscanCount to 5 2018-11-14 08:33:01 +01:00
a7b6442ed9 Update dependencies 2018-11-14 08:25:20 +01:00
374604fae2 v0.5 2018-11-11 07:08:53 +01:00
48f59604a6 Update readme lib list 2018-11-10 12:56:31 +01:00
474afda9c2 Add manifest.json, icons and install button, flatten client/src 2018-11-10 12:33:44 +01:00
a219e689c1 Update vendor dir 2018-11-10 08:04:36 +01:00
9c9b05d479 Update react-redux to 6.0.0-beta.2, enable react concurrent mode, use strict equality mapState checks 2018-11-09 08:11:01 +01:00
04852cdf58 Implement http.Handler in server.Dispatch 2018-11-09 07:32:47 +01:00
4b4b2394a9 Link rel preload boot data 2018-11-09 07:12:43 +01:00
9b6844449d Vendor workbox 2018-11-08 09:39:32 +01:00
9a5d7f8360 Exclude inline chunks from precache manifest 2018-11-08 08:57:24 +01:00
70b2c4df47 Dont refresh session keys on bootloader requests 2018-11-08 08:39:47 +01:00
f86e0d9283 Dont do auth on service worker index page requests 2018-11-07 02:55:00 +01:00
fd6c8a70e2 Append pathname to bootloader request 2018-11-07 02:07:23 +01:00
ed40b956b7 Set publicPath in production webpack config 2018-11-07 01:35:53 +01:00
ca222ff10d Add cache-first service worker 2018-11-06 11:13:32 +01:00
b2b5f82486 Shallow render linkify test 2018-11-05 08:23:22 +01:00
69d5f41270 Fix message rendering 2018-11-05 07:11:15 +01:00
d930365eeb Code split the client, update dependencies 2018-11-04 07:23:07 +01:00
84c3d5cc88 Merge pull request #35 from diadara/patch-2
Update readme
2018-11-01 22:06:19 +01:00
ef34baf5a0 Update readme
Make instructions for starting dev environment explicit
2018-10-31 15:49:41 +07:00
38ed5a367b Make sure list has mounted before using ref 2018-10-26 08:18:31 +02:00
4482dd33ce Migrate from react-virtualized to react-window 2018-10-26 07:00:37 +02:00
675e350da3 Use Cache-Control immutable and SameSite Lax 2018-10-19 02:11:12 +02:00
7658e3bde7 Avoid dispatching unneeded socket actions 2018-10-16 01:04:49 +02:00
7fb0cd3e6a Send default settings when there is no user yet 2018-10-15 09:06:43 +02:00
6c6a9e12cf Add colored nicks settings option 2018-10-15 08:56:17 +02:00
ec03db4db6 Remove base64-arraybuffer 2018-10-10 21:23:42 +02:00
afc80650e7 Use HSLuv for nick colors 2018-10-08 01:34:53 +02:00
6146b27adc Update readme 2018-10-07 01:27:26 +02:00
973aa3b104 Bust dat screenshot cache 2018-10-07 01:15:09 +02:00
6f6dfca5fb Move "Add network" button down 2018-10-07 01:13:58 +02:00
b3f2b53a6f Add colored nicks 2018-10-07 00:25:56 +02:00
937987da82 Drop go 1.9 support 2018-10-06 09:17:59 +02:00
91fd50fe10 Make gen:install work outside GOPATH 2018-10-06 09:15:11 +02:00
3ec450805b Set SameSite Strict on session cookies 2018-10-06 08:56:29 +02:00
09de41c0a2 Use ErrorMessage component 2018-10-06 08:46:28 +02:00
66bf957460 Update dependencies 2018-10-06 08:09:29 +02:00
ccdd56fea4 Explicitly set input background color 2018-09-24 01:10:21 +02:00
963e36c296 Preload fonts 2018-09-18 22:09:08 +02:00
1de1d1473d Drop go 1.8 support 2018-08-31 04:09:07 +02:00
47bc78b80a Update all dependencies 2018-08-31 03:57:19 +02:00
628dc66685 Go modules! 2018-08-31 03:08:17 +02:00
b0b3489e15 Update go version on travis 2018-08-23 22:03:48 +02:00
5defd315f1 Merge pull request #33 from daftaupe/#17
Change Connect button to Add Network
2018-08-23 12:27:49 +02:00
2db878d2cb Change Connect button to Add Network
Signed-off-by: Pierre-Alain TORET <pierre-alain.toret@protonmail.com>
2018-08-23 11:01:44 +02:00
0b49df6bcf Merge pull request #32 from daftaupe/#31
Add command line option for server binding
2018-08-22 15:53:34 +02:00
bd46082cfe Add command line option for server binding
Signed-off-by: Pierre-Alain TORET <pierre-alain.toret@protonmail.com>
2018-08-22 09:16:44 +02:00
508969e189 Update client dependencies 2018-08-22 00:53:45 +02:00
3dea4d6513 Add go 1.11rc1 travis build 2018-08-22 00:36:28 +02:00
c8c09bce8c Add option to choose which address to listen on, closes #31 2018-08-22 00:31:29 +02:00
004e7890e5 Update client dependencies 2018-08-12 23:28:58 +02:00
f20ca4038e Fix connect form readonly casing 2018-08-10 21:35:37 +02:00
1406b87d77 Fix checkbox 2018-08-10 21:30:44 +02:00
c975c5d120 Add option to hex encode the source IP of a client and use it as the ident, closes #24 2018-08-10 21:02:09 +02:00
e2c6cedc27 Merge pull request #30 from daftaupe/#26
Add support for X-Forwarded-For
2018-07-25 00:18:29 +02:00
8ed27bf54b Add support for X-Forwarded-For 2018-07-24 22:01:01 +02:00
a4d2cf17aa Hide original checkbox better 2018-06-20 17:31:59 +02:00
fb287dce2f Fix tests 2018-06-18 16:12:19 +02:00
352f7a8487 Merge pull request #23 from EdwardBetts/spelling
Correct spelling mistakes.
2018-06-18 15:59:17 +02:00
ad238726b7 Correct spelling mistakes. 2018-06-18 14:47:51 +01:00
8f20593b26 Create root directory explicitly, closes #22 2018-06-17 23:47:47 +02:00
fde6a9e630 Wait 15 seconds before reconnecting if the connection closed before registration finished 2018-06-17 22:53:22 +02:00
9669092148 Fix horizontal overflow 2018-06-10 21:35:12 +02:00
3d2dbd5527 Add custom checkbox 2018-06-10 21:25:58 +02:00
b07d1eb871 Update client dependencies 2018-06-07 23:12:09 +02:00
d2c1297cf7 Scroll text inputs into view, use red labels in text inputs when theres an error, only autocapitalize the name field 2018-06-03 06:18:03 +02:00
f5de115534 Remove TTF fonts 2018-06-01 22:10:20 +02:00
494dbc4cf5 Remove session expiration timer 2018-06-01 05:59:13 +02:00
c49bbc72d4 Do all session pruning in a single goroutine 2018-06-01 05:40:12 +02:00
e8f5809940 Do session disk writes in a new goroutine 2018-06-01 04:47:11 +02:00
e0200a2b2a Name all storage interface params, return slices of pointers 2018-06-01 04:16:38 +02:00
09248edd59 Fix irc handler tests 2018-06-01 03:37:22 +02:00
24f9553aa5 Persist, renew and delete sessions, refactor storage package, move reusable packages to pkg 2018-05-31 23:24:59 +02:00
121582f72a Update client dependencies 2018-05-31 23:23:01 +02:00
70322c2e43 Add Sauce Labs credit 2018-05-30 04:42:32 +02:00
9529af55c7 Update prettier to 1.13.0 2018-05-28 03:16:05 +02:00
21b6740e8b Update client dependencies 2018-05-26 00:04:11 +02:00
09d57b7023 Use easyjson 2018-05-25 23:54:36 +02:00
e7cff1686e Clean up some things 2018-05-25 04:12:02 +02:00
e2a895a1b9 Autoprefix css in development 2018-05-25 03:27:53 +02:00
b4c9338772 Change browserslist 2018-05-25 03:26:41 +02:00
637f0d956b Wait until a websocket connection comes in before creating new anonymous sessions 2018-05-22 03:57:52 +02:00
27653982d7 Delete docker.sh 2018-05-19 01:02:50 +02:00
2f2c500453 Use correct ssl property name, closes #14 2018-05-18 05:33:33 +02:00
d27d108a07 Make sure the connect form port is always a string 2018-05-18 05:13:25 +02:00
4ac0dd7c4b Handle default port properly, closes #18 2018-05-18 04:15:20 +02:00
b09da1fc3b Merge pull request #16 from prologic/consistency_config_options
Makes configuration options consistent
2018-05-18 03:50:31 +02:00
2ada552220 Validate port numbers better 2018-05-18 03:39:40 +02:00
16ce3cdfa5 Make configuration options consistent 2018-05-17 18:37:33 -07:00
9806d6c12f Use nick as username and realname if they are missing 2018-05-18 03:14:22 +02:00
8f8adc37e7 Update react-virtualized to 9.19.0 2018-05-18 02:38:58 +02:00
ed0c413542 Merge pull request #12 from prologic/improved_dockerfile
Improved Dockerfile to properly build and ship a runtime image
2018-05-18 00:58:22 +02:00
276d8f7849 Improved Dockerfile to properly build and ship a runtime image 2018-05-17 15:44:15 -07:00
6fd5235ec9 Add new connect form, closes #7 2018-05-16 05:02:48 +02:00
f502fea5c1 Update client dependencies 2018-05-16 05:01:31 +02:00
abc495f849 Update README 2018-05-16 03:54:19 +02:00
4fafe2b158 Update gulp to 4.0.0 2018-05-16 03:46:42 +02:00
29a225ed13 Make it run better in IE11 2018-05-06 21:36:05 +02:00
91e5556c86 Make it run in IE11 2018-05-05 21:54:00 +02:00
de36fe682a Update server dependencies 2018-05-04 23:39:27 +02:00
fb8fec38ff Use correct hash length when reading the push cookie 2018-04-30 22:53:29 +02:00
d4d03eac12 Add promise polyfill 2018-04-30 22:41:12 +02:00
33e0f67766 Add IRCv3 tag parsing 2018-04-29 03:49:02 +02:00
62e115498f Deal with empty ISUPPORT param names 2018-04-29 02:13:22 +02:00
f2504cc245 Add RPL_ISUPPORT parsing 2018-04-28 20:31:44 +02:00
735f96d3b1 Remove unused eslint rule 2018-04-27 03:14:35 +02:00
f72253966b Throw more test cases at the message parser, fix edge case 2018-04-27 02:56:35 +02:00
b4bdcd4939 Update README 2018-04-26 21:37:58 +02:00
0648b67cb8 Use random session IDs instead of jwt 2018-04-26 21:32:21 +02:00
6f0ea05f4b Use shorter asset hashes 2018-04-26 21:19:47 +02:00
1b202e7c2b Make sure the cookie stored channel exists on the correct server 2018-04-26 20:25:41 +02:00
e132c8201f Use node 10.0.0 on travis 2018-04-26 19:57:39 +02:00
4f72e164d7 Use immer 2018-04-25 05:52:55 +02:00
7f755d2a83 Actually make sure the user is in the channel before embedding channel data 2018-04-24 21:21:42 +02:00
8724121552 Update redux to 4.0.0 2018-04-17 21:53:41 +02:00
0ebd2e5c38 Update client dependencies 2018-04-17 21:22:10 +02:00
0941ed8549 Add reset-config flag 2018-04-15 01:25:21 +02:00
39641c315f Add connect defaults readonly flag, closes #10 2018-04-15 01:02:28 +02:00
fcd204321a Fix css bundle name 2018-04-15 00:58:51 +02:00
d08bd43ba0 Hot reload server config 2018-04-15 00:57:11 +02:00
60190fbd98 Update prettier to 1.12 2018-04-13 20:58:45 +02:00
6ccc57ad64 Update gulp compress glob 2018-04-13 20:51:42 +02:00
94f3777f5f Handle css with webpack 2018-04-13 20:47:39 +02:00
3d4c1baeda Remove redundant eslint rules 2018-04-06 01:53:11 +02:00
b176b79144 Add prettier 2018-04-06 01:46:22 +02:00
0cbbc1b8ff Update client deps: react 16.3, babel 7 2018-04-05 21:16:37 +02:00
1ae7d867a9 Use node 8.10.0 on travis 2018-03-25 01:51:54 +01:00
87d7337d21 Update .travis.yml 2018-03-25 01:47:08 +01:00
19bcc51eb4 Update client dependencies 2018-03-25 01:34:41 +01:00
20c3855ced Add go 1.10 travis build 2018-02-19 00:01:48 +01:00
b9b6928111 Add go 1.9 travis build 2017-09-02 20:28:36 +02:00
d22758227d Add link metadata fetching package 2017-07-17 23:11:36 +02:00
a4a4588ae6 Add compareUsers test 2017-07-06 07:01:14 +02:00
8b44f68231 Move user sorting to a selector 2017-07-06 06:46:53 +02:00
c005fc7cae Add initial support for choosing to still connect when the server uses a self-signed cert and verify_vertificates is turned on 2017-07-04 11:28:56 +02:00
3f70567d56 Trim whitespace off IRC messages 2017-07-03 23:31:14 +02:00
8a62af5a73 Unvendor resync 2017-07-03 07:39:10 +02:00
0a96ebb428 Improve connection handling 2017-07-03 07:35:38 +02:00
9dffb541b9 Print ERROR messages 2017-07-03 07:25:38 +02:00
ae6ad0a5b9 Send nick_fail if the new nick is in use and the padded nick is the same as the old one 2017-07-03 01:07:55 +02:00
403f7d0942 Copy action payload before passing it to socket handlers 2017-07-03 00:52:16 +02:00
8a2fbaca7f Improve status tab error layout 2017-07-03 00:04:10 +02:00
18aff3ded6 Show last IRC connection error in status tab, log IRC connection errors 2017-07-02 03:31:00 +02:00
786d8013b9 Set InsecureSkipVerify correctly when theres no client cert, rename verify_client_certificates to verify_certificates 2017-06-30 07:20:38 +02:00
f1e44661b8 v0.4 2017-06-29 07:57:53 +02:00
68bdcfc31b Update client dependencies 2017-06-29 07:50:35 +02:00
5711130776 Add go 1.9 beta travis build 2017-06-29 07:40:46 +02:00
3af6ad9cd9 Only redirect to a stored tab if it still exists 2017-06-29 07:36:58 +02:00
962f7d1eb0 Simplify Settings mapDispatch 2017-06-29 07:11:12 +02:00
3209238562 Only persist tab if its a channel or status tab 2017-06-29 07:06:05 +02:00
6624a97ce7 Keep TabList sorted 2017-06-29 06:56:05 +02:00
7160db9614 Increase scrollback fetch threshold slightly 2017-06-23 03:40:53 +02:00
6c7bf0d81a Use rewire to test unexported functions 2017-06-23 03:06:44 +02:00
0dcfcbbafd Add getMessageTab test 2017-06-23 02:43:36 +02:00
54462b8a1b Add messages sent to channels with dots in the name to the correct tab 2017-06-23 02:27:56 +02:00
60cc5ca139 Update webpack-dev-middleware 2017-06-23 02:08:17 +02:00
f7b80b413e Fix tab cookie 2017-06-21 09:45:47 +02:00
1c199f40e2 Handle import aliases with babel instead of webpack 2017-06-21 09:03:24 +02:00
86c5451edb Organize components, use webpack import aliases 2017-06-21 08:40:28 +02:00
f174d98107 Support changing the nick by clicking it in MessageInput 2017-06-21 07:23:07 +02:00
4a74463ae8 Return empty string from LastParam() if theres no params 2017-06-21 07:20:31 +02:00
a4ebd8d4c4 Update webpack to 3.0.0 2017-06-20 04:26:18 +02:00
37f57b87ac Update react to 15.6.0 and react-virtualized to 9.8.0 2017-06-14 05:28:01 +02:00
0f5c3b57d2 Handle channel names ending with a slash better 2017-06-13 04:25:59 +02:00
f03b30eff6 Update client dependencies 2017-06-12 06:55:22 +02:00
9f8a0d72ba Use verbose client test output on travis 2017-06-12 06:39:24 +02:00
b639ba6846 Support changing the server name by clicking it in the status tab 2017-06-12 06:31:27 +02:00
3b33957161 Fix message reducer tests 2017-06-07 05:13:02 +02:00
b0b9904bc1 Rename state/environment to state/app 2017-06-07 01:03:35 +02:00
1beb56abcf Print some info when running in dev mode 2017-06-07 00:17:46 +02:00
b6e92d6add Fetch scrollback messages earlier, add them when ready 2017-06-07 00:04:37 +02:00
e5c5938414 Add raw command shorthand 2017-06-02 22:24:03 +02:00
6d55ce8a2d Enable dev-middleware CORS 2017-06-01 23:17:15 +02:00
d9e3d71a1f Show warning when disconnected 2017-05-29 06:16:24 +02:00
2d32df3afe Update client dependencies 2017-05-29 03:12:19 +02:00
fa9c5a9c16 Fix navicon 2017-05-29 03:02:39 +02:00
a084e6d0af Dont send empty whois results to the client 2017-05-29 00:52:19 +02:00
0689c74101 Dont forward irc errors that are handled elsewhere 2017-05-29 00:42:00 +02:00
aa59e71745 Forward irc errors to the client, improve command validation and feedback, handle topic changes 2017-05-28 07:20:43 +02:00
993d29242e Clean up container/component relationship 2017-05-27 07:30:22 +02:00
8b0a53b375 Update travis versions 2017-05-27 06:54:00 +02:00
01bacafac8 Add linkify tests 2017-05-27 06:47:17 +02:00
889e3b88b7 Colocate reducers, actions and selectors 2017-05-26 08:20:00 +02:00
1e7d4c3fe4 Update webpack to 2.6.0 2017-05-23 09:58:08 +02:00
46e664118f Update readme download links 2017-05-22 04:21:21 +02:00
1354 changed files with 229376 additions and 67194 deletions

4
.gitignore vendored
View File

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

41
.goreleaser.yml Normal file
View File

@ -0,0 +1,41 @@
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}}
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- 6
- 7
archive:
files:
- none*
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
changelog:
filters:
exclude:
- "(?i)^update.*dep"
- Merge pull request
- Merge branch
release:
name_template: "{{.Version}}"

View File

@ -1,7 +1,7 @@
language: go
go:
- 1.8.1
- 1.x
- tip
os:
@ -15,15 +15,23 @@ matrix:
install:
- go get github.com/jteeuwen/go-bindata/...
- cd client
- nvm install 6.9.4
- nvm use 6.9.4
- nvm install 11.2.0
- nvm use 11.2.0
- npm install -g yarn
- npm install -g gulp
- yarn global add gulp@next
- yarn
script:
- npm test
- yarn test:verbose
- gulp build
- cd ..
- go vet $(go list ./... | grep -v '/vendor/')
- go test -v -race $(go list ./... | grep -v '/vendor/')
- go vet ./...
- go test -v -race ./...
deploy:
- provider: script
skip_cleanup: true
script: git checkout -- . && curl -sL https://git.io/goreleaser | bash
on:
tags: true
condition: $TRAVIS_OS_NAME = linux && $TRAVIS_GO_VERSION = 1.*

View File

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

View File

@ -2,40 +2,46 @@
#### [Try it!](https://dispatch.khlieng.com)
![Dispatch](https://khlieng.com/dispatch.png)
![Dispatch](https://khlieng.com/dispatch.png?1)
### Features
* Searchable history
* Persistent connections
* Multiple servers and users
* Automatic HTTPS through Let's Encrypt
* Client certificates
- Searchable history
- Persistent connections
- Multiple servers and users
- Automatic HTTPS through Let's Encrypt
- Client certificates
## Usage
There is a few different ways of getting it:
### 1. Binary
- **[Windows (x64)](https://github.com/khlieng/dispatch/releases/download/v0.2/dispatch_windows_amd64.zip)**
- **[OS X (x64)](https://github.com/khlieng/dispatch/releases/download/v0.2/dispatch_darwin_amd64.zip)**
- **[Linux (x64)](https://github.com/khlieng/dispatch/releases/download/v0.2/dispatch_linux_amd64.tar.gz)**
- **[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)**
- [Other versions](https://github.com/khlieng/dispatch/releases)
### 2. Go
This requires a [Go environment](http://golang.org/doc/install), version 1.8 or greater.
This requires a [Go environment](http://golang.org/doc/install), version 1.10 or greater.
Fetch, compile and run dispatch:
```bash
go get github.com/khlieng/dispatch
dispatch
```
To get some help run:
```bash
dispatch help
```
### 3. Docker
```bash
docker run -p <http port>:80 -p <https port>:443 -v <path>:/data khlieng/dispatch
```
@ -43,40 +49,51 @@ docker run -p <http port>:80 -p <https port>:443 -v <path>:/data khlieng/dispatc
## Build
### Server
```bash
cd $GOPATH/src/github.com/khlieng/dispatch
go install
```
### Client
This requires [Node.js](https://nodejs.org).
This requires [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com).
Fetch the dependencies:
```bash
npm install -g gulp
go get github.com/jteeuwen/go-bindata/...
yarn global add gulp@next
cd $GOPATH/src/github.com/khlieng/dispatch/client
npm install
yarn
```
Run the build:
```bash
gulp build
```
The server needs to be rebuilt after this.
The server needs to be rebuilt to embed new client builds.
For development with hot reloading start the frontend:
For development with hot reloading enabled run:
```bash
gulp
```
And then the backend in a separate terminal:
```bash
dispatch --dev
```
## Libraries
The libraries this project is built with.
### Server
- [Bolt](https://github.com/boltdb/bolt)
- [Bleve](https://github.com/blevesearch/bleve)
- [Cobra](https://github.com/spf13/cobra)
@ -84,9 +101,15 @@ The libraries this project is built with.
- [Lego](https://github.com/xenolf/lego)
### Client
- [React](https://github.com/facebook/react)
- [Redux](https://github.com/reactjs/redux)
- [React Router](https://github.com/ReactTraining/react-router)
- [React Virtualized](https://github.com/bvaughn/react-virtualized)
- [Immutable](https://github.com/facebook/immutable-js)
- [Immer](https://github.com/mweststrate/immer)
- [react-window](https://github.com/bvaughn/react-window)
- [Lodash](https://github.com/lodash/lodash)
## Big Thanks
Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs][homepage]
[homepage]: https://saucelabs.com

File diff suppressed because one or more lines are too long

View File

@ -1,21 +0,0 @@
{
"presets": [
["es2015", { "modules": false, "loose": true }],
"react",
"stage-0"
],
"env": {
"development": {
"plugins": ["react-hot-loader/babel"]
},
"test": {
"plugins": ["transform-es2015-modules-commonjs"]
},
"production": {
"plugins": [
"transform-react-inline-elements",
"transform-react-constant-elements"
]
}
}
}

32
client/.babelrc.js Normal file
View File

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

View File

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

3
client/.prettierrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@ -1,14 +1,14 @@
@font-face {
font-family: 'fontello';
src: url('../font/fontello.woff2?48901973') format('woff2'),
url('../font/fontello.woff?48901973') format('woff'),
url('../font/fontello.ttf?48901973') format('truetype');
src: url('/font/fontello.woff2?48901973') format('woff2'),
url('/font/fontello.woff?48901973') format('woff');
font-weight: normal;
font-style: normal;
}
[class^="icon-"]:before, [class*=" icon-"]:before {
font-family: "fontello";
[class^='icon-']:before,
[class*=' icon-']:before {
font-family: 'fontello';
font-style: normal;
font-weight: normal;
speak: none;
@ -16,7 +16,7 @@
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
margin-right: 0.2em;
text-align: center;
/* opacity: .8; */
@ -29,7 +29,7 @@
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
margin-left: 0.2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
@ -42,9 +42,21 @@
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.icon-cancel:before { content: '\e800'; } /* '' */
.icon-menu:before { content: '\e801'; } /* '' */
.icon-cog:before { content: '\e802'; } /* '' */
.icon-search:before { content: '\e803'; } /* '' */
.icon-user:before { content: '\f061'; } /* '' */
.icon-ellipsis:before { content: '\f141'; } /* '' */
.icon-cancel:before {
content: '\e800';
} /* '' */
.icon-menu:before {
content: '\e801';
} /* '' */
.icon-cog:before {
content: '\e802';
} /* '' */
.icon-search:before {
content: '\e803';
} /* '' */
.icon-user:before {
content: '\f061';
} /* '' */
.icon-ellipsis:before {
content: '\f141';
} /* '' */

35
client/css/fonts.css Normal file
View File

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

View File

@ -7,59 +7,223 @@
body {
font-family: Roboto Mono, monospace;
background: #f0f0f0;
color: #222;
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: Montserrat, sans-serif;
font-weight: 400;
}
h1 {
font-weight: 700;
}
input {
font: 16px Roboto Mono, monospace;
border: none;
outline: none;
background: #fff;
color: #222;
}
input::-ms-clear {
display: none;
}
button {
width: 100%;
height: 50px;
background: #6bb758;
color: #fff;
font: 16px Montserrat, sans-serif;
border: none;
outline: none;
user-select: none;
cursor: pointer;
}
button:hover {
background: #7bbf6a;
}
button:active {
background: #6bb758;
}
label {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
.checkbox {
display: flex;
align-items: center;
user-select: none;
cursor: pointer;
}
.checkbox.top-label {
flex-direction: column;
}
.checkbox input {
position: absolute;
left: -99999px;
opacity: 0;
}
.checkbox span {
width: 20px;
height: 20px;
border: 2px solid #777;
position: relative;
}
.checkbox:not(.top-label) span {
margin-right: 10px;
}
.checkbox input:checked + span {
background: #6bb758;
border-color: #6bb758;
}
.checkbox input:checked + span:before {
content: '';
width: 5px;
height: 10px;
border-right: 3px solid #fff;
border-bottom: 3px solid #fff;
position: absolute;
color: #fff;
transform: rotate(45deg);
left: 4px;
}
p {
line-height: 1.5;
}
i[class^="icon-"]:before, i[class*=" icon-"]:before {
i[class^='icon-']:before,
i[class*=' icon-']:before {
margin: 0;
}
.tablist {
::selection {
background: #ddd;
color: #000;
}
.success {
color: #6bb758 !important;
}
.error {
color: #f6546a !important;
}
.textinput {
display: block;
position: relative;
}
.textinput input {
padding: 25px 15px 10px;
}
.textinput span {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
user-select: none;
transform: translateZ(0);
transition: all 0.2s, color 0s;
color: #777;
}
.textinput-1 {
font: 12px 'Montserrat', sans-serif;
margin: 15px;
opacity: 0;
transform: translateY(10px);
}
.textinput input:focus + .textinput-1,
.textinput-1.value {
opacity: 1;
transform: translateY(0);
}
.textinput-2 {
margin: 22.5px 15px;
}
.textinput input:focus + .textinput-1 + .textinput-2,
.textinput-2.value {
opacity: 0;
transform: translateY(10px);
}
.form-error {
font-family: 'Montserrat', sans-serif;
background: #f6546a;
color: #fff;
padding: 6px 15px;
font-size: 14px;
}
.wrap {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.app-info {
width: 100%;
font-family: Montserrat, sans-serif;
background: #6bb758;
color: #fff;
text-align: center;
padding: 15px;
}
.app-info-error {
background: #f6546a;
}
.app-container {
position: relative;
flex: 1;
}
.tablist {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 200px;
background: #222;
color: #FFF;
color: #fff;
font-family: Montserrat, sans-serif;
transition: transform .2s;
transition: transform 0.2s;
user-select: none;
}
.tab-container {
position: absolute;
top: 50px;
top: 0;
bottom: 50px;
width: 100%;
overflow: auto;
}
.tablist p {
height: 30px;
padding: 3px 15px;
padding-right: 10px;
cursor: pointer;
@ -75,7 +239,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
.tablist p.selected {
padding-left: 10px;
border-left: 5px solid #6BB758;
border-left: 5px solid #6bb758;
}
.tab-content {
@ -104,60 +268,47 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
color: #999;
}
.button-connect {
width: 100%;
height: 50px;
background: #6BB758;
color: #FFF;
}
.button-connect:hover {
background: #7BBF6A;
}
.button-connect:active {
background: #6BB758;
}
.side-buttons {
position: fixed;
display: flex;
position: absolute;
bottom: 0;
height: 50px;
width: 200px;
text-align: center;
border-top: 1px solid #1d1d1d;
}
.side-buttons i {
display: inline-block;
flex: 100%;
color: #999;
width: 50%;
line-height: 50px;
cursor: pointer;
font-size: 20px;
border-top: 1px solid #1D1D1D;
font-size: 18px;
border-left: 1px solid #1d1d1d;
}
.side-buttons i:not(:first-child) {
border-left: 1px solid #1D1D1D;
.side-buttons button {
font-size: 24px;
}
.side-buttons i:hover {
color: #CCC;
background: #1D1D1D;
color: #ccc;
background: #1d1d1d;
}
.main-container {
position: fixed;
position: absolute;
left: 200px;
top: 0;
bottom: 0;
right: 0;
transition: left .2s, transform .2s;
transition: left 0.2s, transform 0.2s;
}
.connect {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
width: 100%;
@ -165,75 +316,96 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
overflow: auto;
}
.connect .navicon, .settings .navicon {
position: absolute;
.connect .navicon,
.settings .navicon {
position: fixed;
top: 0;
left: 0;
}
.connect-form {
margin: auto 0;
margin: auto 20px;
padding-top: 20px;
width: 300px;
}
.connect-form h1 {
margin-bottom: 15px;
width: 350px;
text-align: center;
}
.connect-form input {
display: block;
margin: 5px 0px;
padding: 15px;
border: none;
.connect-form h1 {
text-align: center;
margin-bottom: 15px;
}
.connect-form input[type="submit"],
.connect-form input[type="text"],
.connect-form input[type="password"] {
.connect-details {
color: #999;
text-align: center;
margin-bottom: 15px;
}
.connect-details h2 {
color: #6bb758;
}
.connect-form input {
margin-top: 5px;
width: 100%;
}
.connect-form input[type="submit"] {
height: 50px;
margin-bottom: 20px;
font-family: Montserrat, sans-serif;
background: #6BB758;
color: #FFF;
cursor: pointer;
input[type='number'] {
appearance: textfield;
}
.connect-form input[type="submit"]:hover {
background: #7BBF6A;
}
.connect-form input[type="submit"]:active {
background: #6BB758;
}
.connect-form input[type="checkbox"] {
display: inline-block;
margin-right: 5px;
vertical-align: middle;
}
.connect-form i {
float: right;
cursor: pointer;
color: #999;
padding: 10px 5px;
font-size: 24px;
}
.connect-form i:hover {
color: #000;
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none !important;
margin: 0;
}
.connect-form label {
display: inline-block;
padding: 10px 0;
color: #333;
user-select: none;
cursor: default;
}
.connect-form button {
margin-bottom: 20px;
}
.connect-form-address {
display: flex;
}
.connect-form-address .textinput:nth-child(1) {
flex: 1;
}
.connect-form-address .textinput:nth-child(2) {
width: 65px;
}
.connect-form-address input {
padding-right: 0;
}
.connect-form-address label {
margin-top: 5px;
font: 12px 'Montserrat', sans-serif;
padding: 10px;
padding-bottom: 0;
text-align: center;
background: #fff;
color: #777;
}
.connect-form i {
display: block;
cursor: pointer;
color: #999;
text-align: center;
font-size: 24px;
padding: 5px 0;
}
.connect-form i:hover {
color: #666;
}
.chat-title-bar {
@ -244,7 +416,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
right: 0;
height: 50px;
line-height: 50px;
border-bottom: 1px solid #DDD;
border-bottom: 1px solid #ddd;
display: flex;
font-size: 20px;
}
@ -270,31 +442,51 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
display: none;
}
.chat-server .userlist-bar, .chat-private .userlist-bar {
.chat-server .userlist,
.chat-private .userlist {
display: none;
}
.chat-server .userlist-bar,
.chat-private .userlist-bar {
display: none;
}
.button-leave {
border-left: 1px solid #DDD;
border-left: 1px solid #ddd;
}
.button-leave:hover {
background: #DDD;
background: #ddd;
}
.button-userlist {
display: none;
border-left: 1px solid #DDD;
border-left: 1px solid #ddd;
}
.chat-server .button-userlist, .chat-private .button-userlist {
.chat-server .button-userlist,
.chat-private .button-userlist {
display: none;
}
.chat-title {
margin-left: 15px;
font-size: 24px;
margin-left: 10px;
padding: 0 5px;
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;
}
.chat-topic-wrap {
@ -306,7 +498,7 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
.chat-topic {
position: absolute;
width: 100%;
bottom: -4px;
top: 3px;
font-size: 16px;
color: #999;
white-space: nowrap;
@ -329,8 +521,8 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
right: 0;
width: 200px;
height: 50px;
border-left: 1px solid #DDD;
border-bottom: 1px solid #DDD;
border-left: 1px solid #ddd;
border-bottom: 1px solid #ddd;
line-height: 50px;
text-align: center;
padding: 0 15px;
@ -363,13 +555,13 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
.search-input-wrap {
display: flex;
width: 100%;
background: #FFF;
border-bottom: 1px solid #DDD;
background: #fff;
border-bottom: 1px solid #ddd;
}
.search i {
padding: 15px;
color: #DDD;
color: #ddd;
}
.search-input {
@ -412,8 +604,9 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
padding-top: 40px;
}
.VirtualScroll {
.messagebox-window {
overflow-x: hidden !important;
overflow-y: scroll !important;
}
.message {
@ -424,23 +617,35 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
color: #999;
}
.message-error {
color: #f6546a;
}
.message-prompt {
font-weight: 700;
font-style: italic;
color: #6bb758;
}
.message-action {
color: #FF6698;
color: #ff6698;
}
.message-time {
font-style: normal;
font-weight: 400;
color: #999;
}
.message-sender {
font-weight: 700;
color: #6BB758;
color: #6bb758;
cursor: pointer;
}
.message a {
text-decoration: none;
color: #0066FF;
color: #0066ff;
}
.message a:hover {
@ -455,19 +660,29 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
height: 50px;
z-index: 1;
display: flex;
border-top: 1px solid #DDD;
background: #FFF;
border-top: 1px solid #ddd;
background: #fff;
}
.message-input-nick {
display: block;
margin: 10px;
line-height: 30px;
height: 30px;
padding: 0 10px;
background: #6BB758;
color: #FFF;
font-family: Montserrat, sans-serif;
background: #6bb758;
color: #fff;
font-family: Montserrat, sans-serif !important;
margin-right: 0;
cursor: pointer;
}
input.message-input-nick {
cursor: text;
}
input.message-input-nick.invalid {
background: #f6546a;
}
.message-input {
@ -483,10 +698,10 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
bottom: 50px;
right: 0;
width: 200px;
border-left: 1px solid #DDD;
border-left: 1px solid #ddd;
background: #f0f0f0;
z-index: 2;
transition: transform .2s;
transition: transform 0.2s;
}
.userlist p {
@ -495,73 +710,109 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
}
.userlist p:hover {
background: #DDD;
background: #ddd;
}
.settings-container {
display: flex;
justify-content: center;
overflow: auto;
height: 100%;
}
.settings {
flex: 1;
max-width: 692px;
text-align: center;
}
.settings p {
color: #999;
.settings-section {
border: 1px solid #ddd;
padding: 15px;
margin: 0 20px;
margin-bottom: 20px;
text-align: left;
}
.settings .checkbox {
margin-top: 15px;
}
.settings h1 {
text-align: center;
margin: 20px;
}
.settings h2 {
margin: 15px;
font-weight: 700;
color: #222;
}
.settings button {
margin: 5px;
color: #FFF;
background: #6BB758;
padding: 10px 20px;
width: 200px;
}
.settings button:hover {
background: #7BBF6A;
}
.settings button:active {
background: #6BB758;
}
.settings div {
display: inline-block;
}
.settings .error {
margin: 10px;
color: #F6546A;
margin-top: 15px;
color: #f6546a;
text-align: center;
}
.input-file {
color: #FFF;
color: #fff;
background: #222 !important;
padding: 10px;
margin: 5px;
width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ReactVirtualized__List {
box-sizing: content-box !important;
outline: none;
.settings-file {
text-align: center;
display: inline-block;
margin-top: 15px;
margin-right: 10px;
}
.rvlist-messages {
padding: 7px 0;
overflow-y: scroll !important;
.button-install {
padding: 0 15px;
width: auto !important;
margin: 20px;
margin-top: 0;
}
.rvlist-users {
padding: 10px 0;
.button-install h2 {
color: #fff;
}
@media (max-width: 906px) {
.settings-file {
display: block;
margin-right: 0;
}
.settings-button {
margin-top: 10px;
}
}
.settings-file p {
margin-bottom: 5px;
color: #999;
}
.settings-cert {
text-align: center;
}
.suspense-fallback {
display: flex;
align-items: center;
justify-content: center;
font: 700 64px 'Montserrat', sans-serif;
height: 100%;
color: #ddd;
}
@media (max-width: 600px) {
@ -591,6 +842,10 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
margin-left: 0;
}
.chat-topic {
font-size: 12px;
}
.userlist-bar {
display: none;
}
@ -603,7 +858,8 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
transform: translateX(0);
}
.chat-channel .chat-title-bar, .chat-channel .messagebox {
.chat-channel .chat-title-bar,
.chat-channel .messagebox {
right: 0;
}
@ -611,10 +867,6 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
display: inline-block;
}
.chat-topic {
display: none;
}
.search {
right: 0;
}
@ -624,4 +876,12 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
margin: auto 50px;
max-width: 400px;
}
.settings-section {
margin-left: 50px;
}
.button-install {
margin-left: 50px;
}
}

View File

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

11
client/js/boot.js Normal file
View File

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

193
client/js/commands.js Normal file
View File

@ -0,0 +1,193 @@
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 { select } from 'state/tab';
import { find } from 'utils';
import createCommandMiddleware, {
beforeHandler,
notFoundHandler
} from './middleware/command';
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',
'/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',
'/say <message> - Send message to the current chat',
'/invite <nick> [channel] - Invite user to the current or specified channel',
'/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',
'/help [command]... - Print help for all or the specified command(s)'
];
const text = content => ({ content });
const error = content => ({ content, type: 'error' });
const prompt = content => ({ content, type: 'prompt' });
const findHelp = cmd =>
find(help, line => line.slice(1, line.indexOf(' ')) === cmd);
export default createCommandMiddleware(COMMAND, {
join({ dispatch, server }, channel) {
if (channel) {
if (channel[0] !== '#') {
return error('Bad channel name');
}
dispatch(join([channel], server));
dispatch(select(server, channel));
} else {
return error('Missing channel');
}
},
part({ dispatch, server, channel, isChannel }, partChannel) {
if (partChannel) {
dispatch(part([partChannel], server));
} else if (isChannel) {
dispatch(part([channel], server));
} else {
return error('This is not a channel');
}
},
nick({ dispatch, server }, nick) {
if (nick) {
dispatch(setNick(nick, server));
} else {
return error('Missing nick');
}
},
quit({ dispatch, server }) {
dispatch(disconnect(server));
},
me({ dispatch, server, channel }, ...message) {
const msg = message.join(' ');
if (msg !== '') {
dispatch(sendMessage(`\x01ACTION ${msg}\x01`, channel, server));
} else {
return error('Messages can not be empty');
}
},
topic({ dispatch, getState, server, channel }, ...newTopic) {
if (newTopic.length > 0) {
dispatch(setTopic(newTopic.join(' '), channel, server));
} else if (channel) {
const { topic } = getState().channels[server][channel];
if (topic) {
return text(topic);
}
}
return 'No topic set';
},
msg({ dispatch, server }, 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));
} else {
return error('Messages can not be empty');
}
},
say({ dispatch, server, 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));
} else {
return error('Messages can not be empty');
}
},
invite({ dispatch, server, channel, isChannel }, user, inviteChannel) {
if (!inviteChannel && !isChannel) {
return error('This is not a channel');
}
if (user && inviteChannel) {
dispatch(invite(user, inviteChannel, server));
} else if (user && channel) {
dispatch(invite(user, channel, server));
} else {
return error('Missing nick');
}
},
kick({ dispatch, server, channel, isChannel }, user) {
if (!isChannel) {
return error('This is not a channel');
}
if (user) {
dispatch(kick(user, channel, server));
} else {
return error('Missing nick');
}
},
whois({ dispatch, server }, user) {
if (user) {
dispatch(whois(user, server));
} else {
return error('Missing nick');
}
},
away({ dispatch, server }, ...message) {
const msg = message.join(' ');
dispatch(away(msg, server));
if (msg !== '') {
return 'Away message set';
}
return 'Away message cleared';
},
raw({ dispatch, server }, ...message) {
if (message.length > 0 && message[0] !== '') {
const cmd = `${message[0].toUpperCase()} ${message.slice(1).join(' ')}`;
dispatch(raw(cmd, server));
return prompt(`=> ${cmd}`);
}
return [prompt('=> /raw'), error('Missing message')];
},
help(_, ...commands) {
if (commands.length > 0) {
const cmdHelp = commands.filter(findHelp).map(findHelp);
if (cmdHelp.length > 0) {
return text(cmdHelp);
}
return error('Unable to find any help :(');
}
return text(help);
},
[beforeHandler](_, command, ...params) {
if (command !== 'raw') {
return prompt(`=> /${command} ${params.join(' ')}`);
}
},
[notFoundHandler](ctx, command, ...params) {
if (command === command.toUpperCase()) {
return this.raw(ctx, command, ...params);
}
return error(`=> /${command}: No such command`);
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,190 @@
import React, { Component } from 'react';
import { createSelector } from 'reselect';
import { Form, withFormik } from 'formik';
import Navicon from 'containers/Navicon';
import Button from 'components/ui/Button';
import Checkbox from 'components/ui/formik/Checkbox';
import TextInput from 'components/ui/TextInput';
import Error from 'components/ui/formik/Error';
import { isValidNick, isValidChannel, isValidUsername, isInt } from 'utils';
const getSortedDefaultChannels = createSelector(
defaults => defaults.channels,
channels => channels.split(',').sort()
);
class Connect extends Component {
state = {
showOptionals: false
};
handleSSLChange = e => {
const { values, setFieldValue } = this.props;
if (e.target.checked && values.port === 6667) {
setFieldValue('port', 6697, false);
} else if (!e.target.checked && values.port === 6697) {
setFieldValue('port', 6667, false);
}
};
handleShowClick = () => {
this.setState(prevState => ({ showOptionals: !prevState.showOptionals }));
};
renderOptionals = () => {
const { hexIP } = this.props;
return (
<div>
{!hexIP && <TextInput name="username" />}
<TextInput name="password" type="password" />
<TextInput name="realname" />
</div>
);
};
render() {
const { defaults, values } = this.props;
const { readOnly, showDetails } = defaults;
let form;
if (readOnly) {
form = (
<Form className="connect-form">
<h1>Connect</h1>
{showDetails && (
<div className="connect-details">
<h2>
{values.host}:{values.port}
</h2>
{getSortedDefaultChannels(values).map(channel => (
<p>{channel}</p>
))}
</div>
)}
<TextInput name="nick" />
<Button type="submit">Connect</Button>
</Form>
);
} else {
form = (
<Form className="connect-form">
<h1>Connect</h1>
<TextInput name="name" autoCapitalize="words" />
<div className="connect-form-address">
<TextInput name="host" noError />
<TextInput name="port" type="number" noError />
<Checkbox
name="tls"
label="SSL"
topLabel
onChange={this.handleSSLChange}
/>
</div>
<Error name="host" />
<Error name="port" />
<TextInput name="nick" />
<TextInput name="channels" />
{this.state.showOptionals && this.renderOptionals()}
<i className="icon-ellipsis" onClick={this.handleShowClick} />
<Button type="submit">Connect</Button>
</Form>
);
}
return (
<div className="connect">
<Navicon />
{form}
</div>
);
}
}
export default withFormik({
enableReinitialize: true,
mapPropsToValues: ({ defaults }) => {
let port = 6667;
if (defaults.port) {
({ port } = defaults);
} else if (defaults.ssl) {
port = 6697;
}
return {
name: defaults.name,
host: defaults.host,
port,
nick: '',
channels: defaults.channels.join(','),
username: '',
password: defaults.password ? ' ' : '',
realname: '',
tls: defaults.ssl
};
},
validate: values => {
Object.keys(values).forEach(k => {
if (typeof values[k] === 'string') {
values[k] = values[k].trim();
}
});
const errors = {};
if (!values.host) {
errors.host = 'Host is required';
} else if (values.host.indexOf('.') < 1) {
errors.host = 'Invalid host';
}
if (!values.port) {
values.port = values.tls ? 6697 : 6667;
} else if (!isInt(values.port, 1, 65535)) {
errors.port = 'Invalid port';
}
if (!values.nick) {
errors.nick = 'Nick is required';
} else if (!isValidNick(values.nick)) {
errors.nick = 'Invalid nick';
}
if (values.username && !isValidUsername(values.username)) {
errors.username = 'Invalid username';
}
values.channels = values.channels
.split(',')
.map(channel => {
channel = channel.trim();
if (channel) {
if (isValidChannel(channel, false)) {
if (channel[0] !== '#') {
channel = `#${channel}`;
}
} else {
errors.channels = 'Invalid channel(s)';
}
}
return channel;
})
.filter(s => s)
.join(',');
return errors;
},
handleSubmit: (values, { props }) => {
const { connect, select, join } = props;
const channels = values.channels.split(',');
delete values.channels;
values.port = `${values.port}`;
connect(values);
select(values.host);
if (channels.length > 0) {
join(channels, values.host);
}
}
})(Connect);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import { createStructuredSelector } from 'reselect';
import App from 'components/App';
import { getConnected } from 'state/app';
import { getSortedChannels } from 'state/channels';
import { getPrivateChats } from 'state/privateChats';
import { getServers } from 'state/servers';
import { getSelectedTab, select } from 'state/tab';
import { getShowTabList, hideMenu } from 'state/ui';
import connect from 'utils/connect';
import { push } from 'utils/router';
const mapState = createStructuredSelector({
channels: getSortedChannels,
connected: getConnected,
privateChats: getPrivateChats,
servers: getServers,
showTabList: getShowTabList,
tab: getSelectedTab,
newVersionAvailable: state => state.app.newVersionAvailable
});
const mapDispatch = { push, select, hideMenu };
export default connect(
mapState,
mapDispatch
)(App);

View File

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

View File

@ -0,0 +1,23 @@
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 { select } from 'state/tab';
import connect from 'utils/connect';
const mapState = createStructuredSelector({
defaults: getConnectDefaults,
hexIP: state => getApp(state).hexIP
});
const mapDispatch = {
join,
connect: connectServer,
select
};
export default connect(
mapState,
mapDispatch
)(Connect);

View File

@ -0,0 +1,12 @@
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);

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import connect from 'utils/connect';
const Route = ({ route, name, children }) => {
if (route === name) {
@ -10,8 +10,8 @@ const Route = ({ route, name, children }) => {
const getRoute = state => state.router.route;
const mapStateToProps = createStructuredSelector({
const mapState = createStructuredSelector({
route: getRoute
});
export default connect(mapStateToProps)(Route);
export default connect(mapState)(Route);

View File

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

35
client/js/index.js Normal file
View File

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

View File

@ -0,0 +1,47 @@
import { addMessages, inform, print } from 'state/messages';
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) };
}
// TODO: Pull this out as convenience action
function process({ dispatch, server, channel }, result) {
if (typeof result === 'string') {
dispatch(inform(result, server, channel));
} else if (Array.isArray(result)) {
if (typeof result[0] === 'string') {
dispatch(inform(result, server, channel));
} else if (typeof result[0] === 'object') {
dispatch(addMessages(result, server, channel));
}
} else if (typeof result === 'object' && result) {
dispatch(print(result.content, server, channel, result.type));
}
}
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 params = words.slice(1);
if (command in handlers) {
const ctx = createContext(store, action);
if (beforeHandler in handlers) {
process(ctx, handlers[beforeHandler](ctx, command, ...params));
}
process(ctx, handlers[command](ctx, ...params));
} else if (notFoundHandler in handlers) {
const ctx = createContext(store, action);
process(ctx, handlers[notFoundHandler](ctx, command, ...params));
}
}
return next(action);
};
}

View File

@ -0,0 +1,35 @@
import { ADD_MESSAGES, ADD_FETCHED_MESSAGES } from 'state/actions';
//
// This middleware handles waiting until MessageBox
// is ready before adding messages to the top
//
const message = store => next => {
const ready = {};
const cache = {};
return action => {
if (action.type === ADD_MESSAGES && action.prepend) {
const key = `${action.server} ${action.channel}`;
if (ready[key]) {
ready[key] = false;
return next(action);
}
cache[key] = action;
} else if (action.type === ADD_FETCHED_MESSAGES) {
const key = `${action.server} ${action.channel}`;
ready[key] = true;
if (cache[key]) {
store.dispatch(cache[key]);
cache[key] = undefined;
}
} else {
return next(action);
}
};
};
export default message;

View File

@ -0,0 +1,36 @@
import debounce from 'lodash/debounce';
const debounceKey = action => {
const { key } = action.socket.debounce;
if (key) {
return `${action.type} ${key}`;
}
return action.type;
};
export default function createSocketMiddleware(socket) {
return () => next => {
const debounced = {};
return action => {
if (action.socket) {
if (action.socket.debounce) {
const key = debounceKey(action);
if (!debounced[key]) {
debounced[key] = debounce((type, data) => {
socket.send(type, data);
debounced[key] = undefined;
}, action.socket.debounce.delay);
}
debounced[key](action.socket.type, action.socket.data);
} else {
socket.send(action.socket.type, action.socket.data);
}
}
return next(action);
};
};
}

View File

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

View File

@ -0,0 +1,22 @@
import FontFaceObserver from 'fontfaceobserver';
import { setCharWidth } from 'state/app';
import { stringWidth } from 'utils';
export default function fonts({ store }) {
let { charWidth } = localStorage;
if (charWidth) {
store.dispatch(setCharWidth(parseFloat(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();
}

View File

@ -1,12 +1,16 @@
import documentTitle from './documentTitle';
import handleSocket from './handleSocket';
import fonts from './fonts';
import initialState from './initialState';
import socket from './socket';
import storage from './storage';
import widthUpdates from './widthUpdates';
export default function runModules(ctx) {
fonts(ctx);
initialState(ctx);
documentTitle(ctx);
handleSocket(ctx);
socket(ctx);
storage(ctx);
widthUpdates(ctx);
}

View File

@ -0,0 +1,77 @@
/* 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 { 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));
}
});
}
export default async function initialState(ctx) {
const env = await window.__init__;
ctx.socket.connect();
loadState(ctx, env);
}

161
client/js/modules/socket.js Normal file
View File

@ -0,0 +1,161 @@
import { socketAction } from 'state/actions';
import { setConnected } from 'state/app';
import {
broadcast,
inform,
print,
addMessage,
addMessages
} from 'state/messages';
import { reconnect } from 'state/servers';
import { select } from 'state/tab';
import { find, normalizeChannel } from 'utils';
function withReason(message, reason) {
return message + (reason ? ` (${reason})` : '');
}
function findChannels(state, server, user) {
const channels = [];
Object.keys(state.channels[server]).forEach(channel => {
if (find(state.channels[server][channel].users, u => u.nick === user)) {
channels.push(channel);
}
});
return channels;
}
export default function handleSocket({
socket,
store: { dispatch, getState }
}) {
const handlers = {
message(message) {
dispatch(addMessage(message, message.server, message.to));
},
pm(message) {
dispatch(addMessage(message, message.server, message.from));
},
messages({ messages, server, to, prepend, next }) {
dispatch(addMessages(messages, server, to, prepend, next));
},
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));
}
}
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 }) {
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));
}
}
},
motd({ content, server }) {
dispatch(addMessages(content.map(line => ({ content: line })), server));
},
whois(data) {
const tab = getState().tab.selected;
dispatch(
print(
[
`Nick: ${data.nick}`,
`Username: ${data.username}`,
`Realname: ${data.realname}`,
`Host: ${data.host}`,
`Server: ${data.server}`,
`Channels: ${data.channels}`
],
tab.server,
tab.name
)
);
},
print(message) {
const tab = getState().tab.selected;
dispatch(addMessage(message, tab.server, tab.name));
},
connection_update({ server, errorType }) {
if (
errorType === 'verify' &&
window.confirm(
'The server is using a self-signed certificate, continue anyway?'
)
) {
dispatch(
reconnect(server, {
skipVerify: true
})
);
}
},
_connected(connected) {
dispatch(setConnected(connected));
}
};
socket.onMessage((type, data) => {
let action;
if (Array.isArray(data)) {
action = { type: socketAction(type), data: [...data] };
} else {
action = { ...data, type: socketAction(type) };
}
if (type in handlers) {
handlers[type](data);
}
if (type.charAt(0) === '_') {
return;
}
dispatch(action);
});
}

View File

@ -0,0 +1,18 @@
import Cookie from 'js-cookie';
import debounce from 'lodash/debounce';
import { getSelectedTab } from 'state/tab';
import { isChannel, stringifyTab } from 'utils';
import { observe } from 'utils/observe';
const saveTab = debounce(
tab => Cookie.set('tab', stringifyTab(tab), { expires: 30 }),
1000
);
export default function storage({ store }) {
observe(store, getSelectedTab, tab => {
if (isChannel(tab) || (tab.server && !tab.name)) {
saveTab(tab);
}
});
}

View File

@ -0,0 +1,31 @@
import { getCharWidth } from 'state/app';
import { updateMessageHeight } from 'state/messages';
import { when } from 'utils/observe';
import { measureScrollBarWidth } from 'utils';
import { addResizeListener } from 'utils/size';
const menuWidth = 200;
const messagePadding = 30;
const smallScreen = 600;
export default function widthUpdates({ store }) {
when(store, getCharWidth, charWidth => {
window.messageIndent = 6 * charWidth;
const scrollBarWidth = measureScrollBarWidth();
let prevWrapWidth;
function updateWidth(windowWidth) {
let wrapWidth = windowWidth - scrollBarWidth - messagePadding;
if (windowWidth > smallScreen) {
wrapWidth -= menuWidth;
}
if (wrapWidth !== prevWrapWidth) {
prevWrapWidth = wrapWidth;
store.dispatch(updateMessageHeight(wrapWidth, charWidth, windowWidth));
}
}
addResizeListener(updateWidth, true);
});
}

View File

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

View File

@ -0,0 +1,20 @@
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();
});
});

View File

@ -1,18 +1,19 @@
import Immutable from 'immutable';
import reducer from '../reducers/channels';
import reducer, { compareUsers, getSortedChannels } from '../channels';
import { connect } from '../servers';
import * as actions from '../actions';
import { connect } from '../actions/server';
describe('reducers/channels', () => {
describe('channel reducer', () => {
it('removes channels on PART', () => {
let state = Immutable.fromJS({
let state = {
srv1: {
chan1: {}, chan2: {}, chan3: {}
chan1: {},
chan2: {},
chan3: {}
},
srv2: {
chan1: {}
}
});
};
state = reducer(state, {
type: actions.PART,
@ -20,7 +21,7 @@ describe('reducers/channels', () => {
channels: ['chan1', 'chan3']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv1: {
chan2: {}
},
@ -36,23 +37,19 @@ describe('reducers/channels', () => {
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.SOCKET_PART,
type: actions.socket.PART,
server: 'srv',
channel: 'chan1',
user: 'nick2'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
{ mode: '', nick: 'nick1', renderName: 'nick1' },
]
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
},
chan2: {
users: [
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
@ -61,12 +58,10 @@ describe('reducers/channels', () => {
it('handles SOCKET_JOIN', () => {
const state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
{ mode: '', nick: 'nick1', renderName: 'nick1' }
]
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
}
}
});
@ -78,17 +73,15 @@ describe('reducers/channels', () => {
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.SOCKET_QUIT,
type: actions.socket.QUIT,
server: 'srv',
user: 'nick2'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
{ mode: '', nick: 'nick1', renderName: 'nick1' }
]
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
},
chan2: {
users: []
@ -103,24 +96,22 @@ describe('reducers/channels', () => {
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.SOCKET_NICK,
type: actions.socket.NICK,
server: 'srv',
old: 'nick1',
new: 'nick3'
oldNick: 'nick1',
newNick: 'nick3'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
{ mode: '', nick: 'nick2', renderName: 'nick2' },
{ mode: '', nick: 'nick3', renderName: 'nick3' }
{ mode: '', nick: 'nick3', renderName: 'nick3' },
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
},
chan2: {
users: [
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
@ -128,45 +119,40 @@ describe('reducers/channels', () => {
it('handles SOCKET_USERS', () => {
const state = reducer(undefined, {
type: actions.SOCKET_USERS,
type: actions.socket.USERS,
server: 'srv',
channel: 'chan1',
users: [
'user3',
'user2',
'@user4',
'user1',
'+user5'
]
users: ['user3', 'user2', '@user4', 'user1', '+user5']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
{ mode: 'o', nick: 'user4', renderName: '@user4' },
{ mode: 'v', nick: 'user5', renderName: '+user5' },
{ mode: '', nick: 'user1', renderName: 'user1' },
{ mode: '', nick: 'user3', renderName: 'user3' },
{ mode: '', nick: 'user2', renderName: 'user2' },
{ mode: '', nick: 'user3', renderName: 'user3' }
{ mode: 'o', nick: 'user4', renderName: '@user4' },
{ mode: '', nick: 'user1', renderName: 'user1' },
{ mode: 'v', nick: 'user5', renderName: '+user5' }
]
}
}
})
});
});
it('handles SOCKET_TOPIC', () => {
const state = reducer(undefined, {
type: actions.SOCKET_TOPIC,
type: actions.socket.TOPIC,
server: 'srv',
channel: 'chan1',
topic: 'the topic'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
topic: 'the topic'
topic: 'the topic',
users: []
}
}
});
@ -179,7 +165,7 @@ describe('reducers/channels', () => {
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', ''));
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
@ -188,29 +174,25 @@ describe('reducers/channels', () => {
]
},
chan2: {
users: [
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
state = reducer(state, socket_mode('srv', 'chan1', 'nick1' ,'v', 'o'));
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'v', 'o'));
state = reducer(state, socket_mode('srv', 'chan1', 'nick2', 'o', ''));
state = reducer(state, socket_mode('srv', 'chan2', 'not_there', 'x', ''));
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
{ mode: 'o', nick: 'nick2', renderName: '@nick2' },
{ mode: 'v', nick: 'nick1', renderName: '+nick1' }
{ mode: 'v', nick: 'nick1', renderName: '+nick1' },
{ mode: 'o', nick: 'nick2', renderName: '@nick2' }
]
},
chan2: {
users: [
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
});
@ -218,7 +200,7 @@ describe('reducers/channels', () => {
it('handles SOCKET_CHANNELS', () => {
const state = reducer(undefined, {
type: actions.SOCKET_CHANNELS,
type: actions.socket.CHANNELS,
data: [
{ server: 'srv', name: 'chan1', topic: 'the topic' },
{ server: 'srv', name: 'chan2' },
@ -226,7 +208,7 @@ describe('reducers/channels', () => {
]
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: { topic: 'the topic', users: [] },
chan2: { users: [] }
@ -239,39 +221,39 @@ describe('reducers/channels', () => {
it('handles SOCKET_SERVERS', () => {
const state = reducer(undefined, {
type: actions.SOCKET_SERVERS,
data: [
{ host: '127.0.0.1' },
{ host: 'thehost' }
]
type: actions.socket.SERVERS,
data: [{ host: '127.0.0.1' }, { host: 'thehost' }]
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {},
thehost: {}
});
});
it('optimistically adds the server on CONNECT', () => {
const state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
const state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {}
});
});
it('removes the server on DISCONNECT', () => {
let state = Immutable.fromJS({
let state = {
srv: {},
srv2: {}
});
};
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv2'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {}
});
});
@ -279,15 +261,75 @@ describe('reducers/channels', () => {
function socket_join(server, channel, user) {
return {
type: 'SOCKET_JOIN',
server, user,
type: actions.socket.JOIN,
server,
user,
channels: [channel]
};
}
function socket_mode(server, channel, user, add, remove) {
return {
type: 'SOCKET_MODE',
server, channel, user, add, remove
type: actions.socket.MODE,
server,
channel,
user,
add,
remove
};
}
describe('compareUsers()', () => {
it('compares users correctly', () => {
expect(
[
{ renderName: 'user5' },
{ renderName: '@user2' },
{ renderName: 'user3' },
{ renderName: 'user2' },
{ renderName: '+user1' },
{ renderName: '~bob' },
{ renderName: '%apples' },
{ renderName: '&cake' }
].sort(compareUsers)
).toEqual([
{ renderName: '~bob' },
{ renderName: '&cake' },
{ renderName: '@user2' },
{ renderName: '%apples' },
{ renderName: '+user1' },
{ renderName: 'user2' },
{ renderName: 'user3' },
{ renderName: 'user5' }
]);
});
});
describe('getSortedChannels', () => {
it('sorts servers and channels', () => {
expect(
getSortedChannels({
channels: {
'bob.com': {},
'127.0.0.1': {
'#chan1': {
users: [],
topic: 'cake'
},
'#pie': {},
'##apples': {}
}
}
})
).toEqual([
{
address: '127.0.0.1',
channels: ['##apples', '#chan1', '#pie']
},
{
address: 'bob.com',
channels: []
}
]);
});
});

View File

@ -1,9 +1,8 @@
import { Map, fromJS } from 'immutable';
import reducer from '../reducers/messages';
import reducer, { broadcast, getMessageTab } from '../messages';
import * as actions from '../actions';
import { broadcast } from '../actions/message';
import appReducer from '../app';
describe('reducers/messages', () => {
describe('message reducer', () => {
it('adds the message on ADD_MESSAGE', () => {
const state = reducer(undefined, {
type: actions.ADD_MESSAGE,
@ -15,17 +14,19 @@ describe('reducers/messages', () => {
}
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
srv: {
'#chan1': [{
from: 'foo',
content: 'msg'
}]
'#chan1': [
{
from: 'foo',
content: 'msg'
}
]
}
});
});
it('adds all the messsages on ADD_MESSAGES', () => {
it('adds all the messages on ADD_MESSAGES', () => {
const state = reducer(undefined, {
type: actions.ADD_MESSAGES,
server: 'srv',
@ -34,10 +35,12 @@ describe('reducers/messages', () => {
{
from: 'foo',
content: 'msg'
}, {
},
{
from: 'bar',
content: 'msg'
}, {
},
{
tab: '#chan2',
from: 'foo',
content: 'msg'
@ -45,31 +48,34 @@ describe('reducers/messages', () => {
]
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
srv: {
'#chan1': [
{
from: 'foo',
content: 'msg'
}, {
},
{
from: 'bar',
content: 'msg'
}
],
'#chan2': [{
from: 'foo',
content: 'msg'
}]
'#chan2': [
{
from: 'foo',
content: 'msg'
}
]
}
});
});
it('handles prepending of messages on ADD_MESSAGES', () => {
let state = fromJS({
let state = {
srv: {
'#chan1': [{ id: 0 }]
}
});
};
state = reducer(state, {
type: actions.ADD_MESSAGES,
@ -79,7 +85,7 @@ describe('reducers/messages', () => {
messages: [{ id: 1 }, { id: 2 }]
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
srv: {
'#chan1': [{ id: 1 }, { id: 2 }, { id: 0 }]
}
@ -88,19 +94,18 @@ describe('reducers/messages', () => {
it('adds messages to the correct tabs when broadcasting', () => {
let state = {
environment: Map({
charWidth: 0,
wrapWidth: 0
})
app: appReducer(undefined, { type: '' })
};
const thunk = broadcast('test', 'srv', ['#chan1', '#chan3']);
thunk(
action => { state.messages = reducer(undefined, action); },
action => {
state.messages = reducer(undefined, action);
},
() => state
);
const messages = state.messages.toJS();
const messages = state.messages;
expect(messages.srv).not.toHaveProperty('srv');
expect(messages.srv['#chan1']).toHaveLength(1);
@ -110,54 +115,38 @@ describe('reducers/messages', () => {
});
it('deletes all messages related to server when disconnecting', () => {
let state = fromJS({
let state = {
srv: {
'#chan1': [
{ content: 'msg1' },
{ content: 'msg2' }
],
'#chan2': [
{ content: 'msg' }
]
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [
{ content: 'msg' }
]
'#chan1': [{ content: 'msg' }]
}
});
};
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv2: {
'#chan1': [
{ content: 'msg' }
]
'#chan1': [{ content: 'msg' }]
}
});
});
it('deletes all messages related to channel when parting', () => {
let state = fromJS({
let state = {
srv: {
'#chan1': [
{ content: 'msg1' },
{ content: 'msg2' }
],
'#chan2': [
{ content: 'msg' }
]
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [
{ content: 'msg' }
]
'#chan1': [{ content: 'msg' }]
}
});
};
state = reducer(state, {
type: actions.PART,
@ -165,17 +154,31 @@ describe('reducers/messages', () => {
channels: ['#chan1']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
'#chan2': [
{ content: 'msg' }
]
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [
{ content: 'msg' }
]
'#chan1': [{ content: 'msg' }]
}
});
});
});
describe('getMessageTab()', () => {
it('returns the correct tab', () => {
const srv = 'chat.freenode.net';
[
['#cake', '#cake'],
['#apple.pie', '#apple.pie'],
['bob', 'bob'],
[undefined, srv],
[null, srv],
['*', srv],
[srv, srv],
['beans.freenode.net', srv]
].forEach(([target, expected]) =>
expect(getMessageTab(srv, target)).toBe(expected)
);
});
});

View File

@ -0,0 +1,273 @@
import reducer, { connect, setServerName } from '../servers';
import * as actions from '../actions';
describe('server reducer', () => {
it('adds the server on CONNECT', () => {
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
}
});
state = reducer(state, connect({ host: '127.0.0.1', nick: 'nick' }));
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
}
});
state = reducer(
state,
connect({ host: '127.0.0.2', nick: 'nick', name: 'srv' })
);
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
},
'127.0.0.2': {
name: 'srv',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
}
});
});
it('removes the server on DISCONNECT', () => {
let state = {
srv: {},
srv2: {}
};
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv2'
});
expect(state).toEqual({
srv: {}
});
});
it('handles SET_SERVER_NAME', () => {
let state = {
srv: {
name: 'cake'
}
};
state = reducer(state, setServerName('pie', 'srv'));
expect(state).toEqual({
srv: {
name: 'pie'
}
});
});
it('sets editedNick when editing the nick', () => {
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
nick: 'nick2',
editing: true
});
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: 'nick2'
}
});
});
it('clears editedNick when receiving an empty nick after editing finishes', () => {
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
nick: 'nick2',
editing: true
});
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
nick: ''
});
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null
}
});
});
it('updates the nick on SOCKET_NICK', () => {
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.socket.NICK,
server: '127.0.0.1',
oldNick: 'nick',
newNick: 'nick2'
});
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick2',
editedNick: null
}
});
});
it('clears editedNick on SOCKET_NICK_FAIL', () => {
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
nick: 'nick2',
editing: true
});
state = reducer(state, {
type: actions.socket.NICK_FAIL,
server: '127.0.0.1'
});
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null
}
});
});
it('adds the servers on SOCKET_SERVERS', () => {
let state = reducer(undefined, {
type: actions.socket.SERVERS,
data: [
{
host: '127.0.0.1',
name: 'stuff',
nick: 'nick',
status: {
connected: true
}
},
{
host: '127.0.0.2',
name: 'stuffz',
nick: 'nick2',
status: {
connected: false
}
}
]
});
expect(state).toEqual({
'127.0.0.1': {
name: 'stuff',
nick: 'nick',
editedNick: null,
status: {
connected: true
}
},
'127.0.0.2': {
name: 'stuffz',
nick: 'nick2',
editedNick: null,
status: {
connected: false
}
}
});
});
it('updates connection status on SOCKET_CONNECTION_UPDATE', () => {
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
);
state = reducer(state, {
type: actions.socket.CONNECTION_UPDATE,
server: '127.0.0.1',
connected: true
});
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: true
}
}
});
state = reducer(state, {
type: actions.socket.CONNECTION_UPDATE,
server: '127.0.0.1',
connected: false,
error: 'Bad stuff happened'
});
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: 'Bad stuff happened'
}
}
});
});
});

View File

@ -1,22 +1,19 @@
import reducer from '../reducers/tab';
import reducer, { setSelectedTab } from '../tab';
import * as actions from '../actions';
import { setSelectedTab } from '../actions/tab';
import { locationChanged } from '../util/router';
import { locationChanged } from 'utils/router';
describe('reducers/tab', () => {
describe('tab reducer', () => {
it('selects the tab and adds it to history', () => {
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan' },
history: [
{ server: 'srv', name: '#chan' }
]
history: [{ server: 'srv', name: '#chan' }]
});
state = reducer(state, setSelectedTab('srv', 'user1'));
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: 'user1' },
history: [
{ server: 'srv', name: '#chan' },
@ -37,7 +34,7 @@ describe('reducers/tab', () => {
channels: ['#chan']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
history: [
{ server: 'srv1', name: 'bob' },
@ -58,12 +55,12 @@ describe('reducers/tab', () => {
nick: 'bob'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
history: [
{ server: 'srv', name: '#chan' },
{ server: 'srv', name: '#chan' },
{ server: 'srv', name: '#chan3' }
{ server: 'srv', name: '#chan3' }
]
});
});
@ -76,14 +73,12 @@ describe('reducers/tab', () => {
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv',
server: 'srv'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
history: [
{ server: 'srv1', name: 'bob' },
]
history: [{ server: 'srv1', name: 'bob' }]
});
});
@ -92,27 +87,24 @@ describe('reducers/tab', () => {
state = reducer(state, locationChanged('settings'));
expect(state.toJS()).toEqual({
selected: { server: null, name: null },
history: [
{ server: 'srv', name: '#chan' }
]
expect(state).toEqual({
selected: {},
history: [{ server: 'srv', name: '#chan' }]
});
});
it('selects the tab and adds it to history when navigating to a tab', () => {
const state = reducer(undefined,
const state = reducer(
undefined,
locationChanged('chat', {
server: 'srv',
name: '#chan'
})
);
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan' },
history: [
{ server: 'srv', name: '#chan' }
]
history: [{ server: 'srv', name: '#chan' }]
});
});
});

View File

@ -1,48 +1,77 @@
export const ADD_MESSAGE = 'ADD_MESSAGE';
export const ADD_MESSAGES = 'ADD_MESSAGES';
export const AWAY = 'AWAY';
export const CLOSE_PRIVATE_CHAT = 'CLOSE_PRIVATE_CHAT';
export const COMMAND = 'COMMAND';
export const CONNECT = 'CONNECT';
export const DISCONNECT = 'DISCONNECT';
export const FETCH_MESSAGES = 'FETCH_MESSAGES';
export const HIDE_MENU = 'HIDE_MENU';
export const APP_SET = 'APP_SET';
export const INVITE = 'INVITE';
export const JOIN = 'JOIN';
export const KICK = 'KICK';
export const PART = 'PART';
export const SET_TOPIC = 'SET_TOPIC';
export const INPUT_HISTORY_ADD = 'INPUT_HISTORY_ADD';
export const INPUT_HISTORY_DECREMENT = 'INPUT_HISTORY_DECREMENT';
export const INPUT_HISTORY_INCREMENT = 'INPUT_HISTORY_INCREMENT';
export const INPUT_HISTORY_RESET = 'INPUT_HISTORY_RESET';
export const INVITE = 'INVITE';
export const JOIN = 'JOIN';
export const KICK = 'KICK';
export const OPEN_PRIVATE_CHAT = 'OPEN_PRIVATE_CHAT';
export const PART = 'PART';
export const ADD_FETCHED_MESSAGES = 'ADD_FETCHED_MESSAGES';
export const ADD_MESSAGE = 'ADD_MESSAGE';
export const ADD_MESSAGES = 'ADD_MESSAGES';
export const COMMAND = 'COMMAND';
export const FETCH_MESSAGES = 'FETCH_MESSAGES';
export const RAW = 'RAW';
export const UPDATE_MESSAGE_HEIGHT = 'UPDATE_MESSAGE_HEIGHT';
export const CLOSE_PRIVATE_CHAT = 'CLOSE_PRIVATE_CHAT';
export const OPEN_PRIVATE_CHAT = 'OPEN_PRIVATE_CHAT';
export const SEARCH_MESSAGES = 'SEARCH_MESSAGES';
export const SELECT_TAB = 'SELECT_TAB';
export const TOGGLE_SEARCH = 'TOGGLE_SEARCH';
export const AWAY = 'AWAY';
export const CONNECT = 'CONNECT';
export const DISCONNECT = 'DISCONNECT';
export const RECONNECT = 'RECONNECT';
export const SET_NICK = 'SET_NICK';
export const SET_SERVER_NAME = 'SET_SERVER_NAME';
export const WHOIS = 'WHOIS';
export const SET_CERT = 'SET_CERT';
export const SET_CERT_ERROR = 'SET_CERT_ERROR';
export const SET_ENVIRONMENT = 'SET_ENVIRONMENT';
export const SET_KEY = 'SET_KEY';
export const SET_NICK = 'SET_NICK';
export const SOCKET_CERT_FAIL = 'SOCKET_CERT_FAIL';
export const SOCKET_CERT_SUCCESS = 'SOCKET_CERT_SUCCESS';
export const SOCKET_CHANNELS = 'SOCKET_CHANNELS';
export const SOCKET_CONNECTION_UPDATE = 'SOCKET_CONNECTION_UPDATE';
export const SOCKET_JOIN = 'SOCKET_JOIN';
export const SOCKET_MESSAGE = 'SOCKET_MESSAGE';
export const SOCKET_MODE = 'SOCKET_MODE';
export const SOCKET_NICK = 'SOCKET_NICK';
export const SOCKET_PART = 'SOCKET_PART';
export const SOCKET_PM = 'SOCKET_PM';
export const SOCKET_QUIT = 'SOCKET_QUIT';
export const SOCKET_SEARCH = 'SOCKET_SEARCH';
export const SOCKET_SERVERS = 'SOCKET_SERVERS';
export const SOCKET_TOPIC = 'SOCKET_TOPIC';
export const SOCKET_USERS = 'SOCKET_USERS';
export const TAB_HISTORY_POP = 'TAB_HISTORY_POP';
export const TOGGLE_MENU = 'TOGGLE_MENU';
export const TOGGLE_SEARCH = 'TOGGLE_SEARCH';
export const TOGGLE_USERLIST = 'TOGGLE_USERLIST';
export const UPDATE_MESSAGE_HEIGHT = 'UPDATE_MESSAGE_HEIGHT';
export const UPLOAD_CERT = 'UPLOAD_CERT';
export const WHOIS = 'WHOIS';
export const SETTINGS_SET = 'SETTINGS_SET';
export const SELECT_TAB = 'SELECT_TAB';
export const HIDE_MENU = 'HIDE_MENU';
export const TOGGLE_MENU = 'TOGGLE_MENU';
export const TOGGLE_USERLIST = 'TOGGLE_USERLIST';
export function socketAction(type) {
return `SOCKET_${type.toUpperCase()}`;
}
function createSocketActions(types) {
const actions = {};
types.forEach(type => {
actions[type.toUpperCase()] = socketAction(type);
});
return actions;
}
export const socket = createSocketActions([
'cert_fail',
'cert_success',
'channels',
'connection_update',
'join',
'message',
'mode',
'nick_fail',
'nick',
'part',
'pm',
'quit',
'search',
'servers',
'topic',
'users'
]);

60
client/js/state/app.js Normal file
View File

@ -0,0 +1,60 @@
import createReducer from 'utils/createReducer';
import * as actions from './actions';
export const getApp = state => state.app;
export const getConnected = state => state.app.connected;
export const getWrapWidth = state => state.app.wrapWidth;
export const getCharWidth = state => state.app.charWidth;
export const getWindowWidth = state => state.app.windowWidth;
export const getConnectDefaults = state => state.app.connectDefaults;
const initialState = {
connected: true,
wrapWidth: 0,
charWidth: 0,
windowWidth: 0,
connectDefaults: {
name: '',
address: '',
channels: [],
ssl: false,
password: false,
readonly: false,
showDetails: false
},
hexIP: false,
newVersionAvailable: false,
installable: null
};
export default createReducer(initialState, {
[actions.APP_SET](state, { key, value }) {
state[key] = value;
},
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
state.wrapWidth = action.wrapWidth;
state.charWidth = action.charWidth;
state.windowWidth = action.windowWidth;
}
});
export function appSet(key, value) {
return {
type: actions.APP_SET,
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);
}

279
client/js/state/channels.js Normal file
View File

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

View File

@ -1,6 +1,6 @@
import { combineReducers } from 'redux';
import app from './app';
import channels from './channels';
import environment from './environment';
import input from './input';
import messages from './messages';
import privateChats from './privateChats';
@ -10,11 +10,14 @@ import settings from './settings';
import tab from './tab';
import ui from './ui';
export * from './selectors';
export const getRouter = state => state.router;
export default function createReducer(router) {
return combineReducers({
router,
app,
channels,
environment,
input,
messages,
privateChats,

69
client/js/state/input.js Normal file
View File

@ -0,0 +1,69 @@
import createReducer from 'utils/createReducer';
import * as actions from './actions';
const HISTORY_MAX_LENGTH = 128;
const initialState = {
history: [],
index: 0
};
export const getCurrentInputHistoryEntry = state => {
if (state.input.index === -1) {
return null;
}
return state.input.history[state.input.index];
};
export default createReducer(initialState, {
[actions.INPUT_HISTORY_ADD](state, { line }) {
if (line.trim() && line !== state.history[0]) {
if (state.history.length === HISTORY_MAX_LENGTH) {
state.history.pop();
}
state.history.unshift(line);
}
},
[actions.INPUT_HISTORY_RESET](state) {
state.index = -1;
},
[actions.INPUT_HISTORY_INCREMENT](state) {
if (state.index < state.history.length - 1) {
state.index++;
}
},
[actions.INPUT_HISTORY_DECREMENT](state) {
if (state.index >= 0) {
state.index--;
}
}
});
export function addInputHistory(line) {
return {
type: actions.INPUT_HISTORY_ADD,
line
};
}
export function resetInputHistory() {
return {
type: actions.INPUT_HISTORY_RESET
};
}
export function incrementInputHistory() {
return {
type: actions.INPUT_HISTORY_INCREMENT
};
}
export function decrementInputHistory() {
return {
type: actions.INPUT_HISTORY_DECREMENT
};
}

314
client/js/state/messages.js Normal file
View File

@ -0,0 +1,314 @@
import { createSelector } from 'reselect';
import has from 'lodash/has';
import {
findBreakpoints,
messageHeight,
linkify,
timestamp,
isChannel
} from 'utils';
import createReducer from 'utils/createReducer';
import { getApp } from './app';
import { getSelectedTab } from './tab';
import * as actions from './actions';
export const getMessages = state => state.messages;
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];
}
return [];
}
);
export const getHasMoreMessages = createSelector(
getSelectedMessages,
messages => {
const first = messages[0];
return first && first.next;
}
);
function init(state, server, tab) {
if (!state[server]) {
state[server] = {};
}
if (!state[server][tab]) {
state[server][tab] = [];
}
}
export default createReducer(
{},
{
[actions.ADD_MESSAGE](state, { server, tab, message }) {
init(state, server, tab);
state[server][tab].push(message);
},
[actions.ADD_MESSAGES](state, { server, tab, messages, prepend }) {
if (prepend) {
init(state, server, tab);
state[server][tab].unshift(...messages);
} else {
messages.forEach(message => {
init(state, server, message.tab || tab);
state[server][message.tab || tab].push(message);
});
}
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
},
[actions.PART](state, { server, channels }) {
channels.forEach(channel => delete state[server][channel]);
},
[actions.UPDATE_MESSAGE_HEIGHT](
state,
{ wrapWidth, charWidth, windowWidth }
) {
Object.keys(state).forEach(server =>
Object.keys(state[server]).forEach(target =>
state[server][target].forEach(message => {
message.height = messageHeight(
message,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
})
)
);
},
[actions.socket.SERVERS](state, { data }) {
if (data) {
data.forEach(({ host }) => {
state[host] = {};
});
}
}
}
);
let nextID = 0;
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) {
if (!to || to === '*' || (!isChannel(to) && to.indexOf('.') !== -1)) {
return server;
}
return to;
}
export function fetchMessages() {
return (dispatch, getState) => {
const state = getState();
const first = getSelectedMessages(state)[0];
if (!first) {
return;
}
const tab = state.tab.selected;
if (isChannel(tab)) {
dispatch({
type: actions.FETCH_MESSAGES,
socket: {
type: 'fetch_messages',
data: {
server: tab.server,
channel: tab.name,
next: first.id
}
}
});
}
};
}
export function addFetchedMessages(server, tab) {
return {
type: actions.ADD_FETCHED_MESSAGES,
server,
tab
};
}
export function updateMessageHeight(wrapWidth, charWidth, windowWidth) {
return {
type: actions.UPDATE_MESSAGE_HEIGHT,
wrapWidth,
charWidth,
windowWidth
};
}
export function sendMessage(content, to, server) {
return (dispatch, getState) => {
const state = getState();
dispatch({
type: actions.ADD_MESSAGE,
server,
tab: to,
message: initMessage(
{
from: state.servers[server].nick,
content
},
to,
state
),
socket: {
type: 'message',
data: { content, to, server }
}
});
};
}
export function addMessage(message, server, to) {
const tab = getMessageTab(server, to);
return (dispatch, getState) =>
dispatch({
type: actions.ADD_MESSAGE,
server,
tab,
message: initMessage(message, tab, getState())
});
}
export function addMessages(messages, server, to, prepend, next) {
const tab = getMessageTab(server, to);
return (dispatch, getState) => {
const state = getState();
if (next) {
messages[0].id = next;
messages[0].next = true;
}
messages.forEach(message =>
initMessage(message, message.tab || tab, state)
);
dispatch({
type: actions.ADD_MESSAGES,
server,
tab,
messages,
prepend
});
};
}
export function broadcast(message, server, channels) {
return addMessages(
channels.map(channel => ({
tab: channel,
content: message,
type: 'info'
})),
server
);
}
export function print(message, server, channel, type) {
if (Array.isArray(message)) {
return addMessages(
message.map(line => ({
content: line,
type
})),
server,
channel
);
}
return addMessage(
{
content: message,
type
},
server,
channel
);
}
export function inform(message, server, channel) {
return print(message, server, channel, 'info');
}
export function runCommand(command, channel, server) {
return {
type: actions.COMMAND,
command,
channel,
server
};
}
export function raw(message, server) {
return {
type: actions.RAW,
message,
server,
socket: {
type: 'raw',
data: { message, server }
}
};
}

View File

@ -0,0 +1,62 @@
import sortBy from 'lodash/sortBy';
import { findIndex } from 'utils';
import createReducer from 'utils/createReducer';
import { updateSelection } from './tab';
import * as actions from './actions';
export const getPrivateChats = state => state.privateChats;
function open(state, server, nick) {
if (!state[server]) {
state[server] = [];
}
if (findIndex(state[server], n => n === nick) === -1) {
state[server].push(nick);
state[server] = sortBy(state[server], v => v.toLowerCase());
}
}
export default createReducer(
{},
{
[actions.OPEN_PRIVATE_CHAT](state, action) {
open(state, action.server, action.nick);
},
[actions.CLOSE_PRIVATE_CHAT](state, { server, nick }) {
const i = findIndex(state[server], n => n === nick);
if (i !== -1) {
state[server].splice(i, 1);
}
},
[actions.socket.PM](state, action) {
if (action.from.indexOf('.') === -1) {
open(state, action.server, action.from);
}
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
}
}
);
export function openPrivateChat(server, nick) {
return {
type: actions.OPEN_PRIVATE_CHAT,
server,
nick
};
}
export function closePrivateChat(server, nick) {
return dispatch => {
dispatch({
type: actions.CLOSE_PRIVATE_CHAT,
server,
nick
});
dispatch(updateSelection());
};
}

38
client/js/state/search.js Normal file
View File

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

View File

@ -0,0 +1,11 @@
import { createSelector } from 'reselect';
import get from 'lodash/get';
import { getServers } from './servers';
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'])
);

210
client/js/state/servers.js Normal file
View File

@ -0,0 +1,210 @@
import { createSelector } from 'reselect';
import get from 'lodash/get';
import createReducer from 'utils/createReducer';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
export const getServers = state => state.servers;
export const getCurrentNick = createSelector(
getServers,
getSelectedTab,
(servers, tab) => {
if (!servers[tab.server]) {
return;
}
const { editedNick } = servers[tab.server];
if (editedNick === null) {
return servers[tab.server].nick;
}
return editedNick;
}
);
export const getCurrentServerName = createSelector(
getServers,
getSelectedTab,
(servers, tab) => get(servers, [tab.server, 'name'])
);
export const getCurrentServerStatus = createSelector(
getServers,
getSelectedTab,
(servers, tab) => get(servers, [tab.server, 'status'], {})
);
export default createReducer(
{},
{
[actions.CONNECT](state, { host, nick, name }) {
if (!state[host]) {
state[host] = {
nick,
editedNick: null,
name: name || host,
status: {
connected: false,
error: null
}
};
}
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
},
[actions.SET_SERVER_NAME](state, { server, name }) {
state[server].name = name;
},
[actions.SET_NICK](state, { server, nick, editing }) {
if (editing) {
state[server].editedNick = nick;
} else if (nick === '') {
state[server].editedNick = null;
}
},
[actions.socket.NICK](state, { server, oldNick, newNick }) {
if (!oldNick || oldNick === state[server].nick) {
state[server].nick = newNick;
state[server].editedNick = null;
}
},
[actions.socket.NICK_FAIL](state, { server }) {
state[server].editedNick = null;
},
[actions.socket.SERVERS](state, { data }) {
if (data) {
data.forEach(({ host, name, nick, status }) => {
state[host] = { name, nick, status, editedNick: null };
});
}
},
[actions.socket.CONNECTION_UPDATE](state, { server, connected, error }) {
if (state[server]) {
state[server].status.connected = connected;
state[server].status.error = error;
}
}
}
);
export function connect(config) {
return {
type: actions.CONNECT,
...config,
socket: {
type: 'connect',
data: config
}
};
}
export function disconnect(server) {
return dispatch => {
dispatch({
type: actions.DISCONNECT,
server,
socket: {
type: 'quit',
data: { server }
}
});
dispatch(updateSelection());
};
}
export function reconnect(server, settings) {
return {
type: actions.RECONNECT,
server,
settings,
socket: {
type: 'reconnect',
data: {
...settings,
server
}
}
};
}
export function whois(user, server) {
return {
type: actions.WHOIS,
user,
server,
socket: {
type: 'whois',
data: { user, server }
}
};
}
export function away(message, server) {
return {
type: actions.AWAY,
message,
server,
socket: {
type: 'away',
data: { message, server }
}
};
}
export function setNick(nick, server, editing) {
nick = nick.trim().replace(' ', '');
const action = {
type: actions.SET_NICK,
nick,
server,
editing
};
if (!editing && nick !== '') {
action.socket = {
type: 'nick',
data: {
newNick: nick,
server
}
};
}
return action;
}
export function isValidServerName(name) {
return name.trim() !== '';
}
export function setServerName(name, server) {
const action = {
type: actions.SET_SERVER_NAME,
name,
server
};
if (isValidServerName(name)) {
action.socket = {
type: 'set_server_name',
data: {
name,
server
},
debounce: {
delay: 500,
key: `server_name:${server}`
}
};
}
return action;
}

127
client/js/state/settings.js Normal file
View File

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

84
client/js/state/tab.js Normal file
View File

@ -0,0 +1,84 @@
import createReducer from 'utils/createReducer';
import { push, replace, LOCATION_CHANGED } from 'utils/router';
import * as actions from './actions';
const initialState = {
selected: {},
history: []
};
function selectTab(state, action) {
state.selected = {
server: action.server,
name: action.name
};
state.history.push(state.selected);
}
export const getSelectedTab = state => state.tab.selected;
export default createReducer(initialState, {
[actions.SELECT_TAB]: selectTab,
[actions.PART](state, action) {
state.history = state.history.filter(
tab => !(tab.server === action.server && tab.name === action.channels[0])
);
},
[actions.CLOSE_PRIVATE_CHAT](state, action) {
state.history = state.history.filter(
tab => !(tab.server === action.server && tab.name === action.nick)
);
},
[actions.DISCONNECT](state, action) {
state.history = state.history.filter(tab => tab.server !== action.server);
},
[LOCATION_CHANGED](state, action) {
const { route, params } = action;
if (route === 'chat') {
selectTab(state, params);
} else {
state.selected = {};
}
}
});
export function select(server, name, doReplace) {
const navigate = doReplace ? replace : push;
if (name) {
return navigate(`/${server}/${encodeURIComponent(name)}`);
}
return navigate(`/${server}`);
}
export function updateSelection() {
return (dispatch, getState) => {
const state = getState();
const { history } = state.tab;
const { servers } = state;
const { server } = state.tab.selected;
const serverAddrs = Object.keys(servers);
if (serverAddrs.length === 0) {
dispatch(replace('/connect'));
} else if (history.length > 0) {
const tab = history[history.length - 1];
dispatch(select(tab.server, tab.name, true));
} else if (servers[server]) {
dispatch(select(server, null, true));
} else {
dispatch(select(serverAddrs.sort()[0], null, true));
}
};
}
export function setSelectedTab(server, name = null) {
return {
type: actions.SELECT_TAB,
server,
name
};
}

46
client/js/state/ui.js Normal file
View File

@ -0,0 +1,46 @@
import createReducer from 'utils/createReducer';
import { LOCATION_CHANGED } from 'utils/router';
import * as actions from './actions';
const initialState = {
showTabList: false,
showUserList: false
};
export const getShowTabList = state => state.ui.showTabList;
export const getShowUserList = state => state.ui.showUserList;
function setMenuHidden(state) {
state.showTabList = false;
}
export default createReducer(initialState, {
[actions.TOGGLE_MENU](state) {
state.showTabList = !state.showTabList;
},
[actions.HIDE_MENU]: setMenuHidden,
[LOCATION_CHANGED]: setMenuHidden,
[actions.TOGGLE_USERLIST](state) {
state.showUserList = !state.showUserList;
}
});
export function hideMenu() {
return {
type: actions.HIDE_MENU
};
}
export function toggleMenu() {
return {
type: actions.TOGGLE_MENU
};
}
export function toggleUserList() {
return {
type: actions.TOGGLE_USERLIST
};
}

30
client/js/store.js Normal file
View File

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

6
client/js/sw.js Normal file
View File

@ -0,0 +1,6 @@
workbox.skipWaiting();
workbox.clientsClaim();
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
ignoreUrlParametersMatching: [/.*/]
});

View File

@ -3,7 +3,7 @@ import Backoff from 'backo';
export default class Socket {
constructor(host) {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
this.url = `${protocol}://${host}/ws?path=${window.location.pathname}`;
this.url = `${protocol}://${host}/ws${window.location.pathname}`;
this.connectTimeout = 20000;
this.pingTimeout = 30000;
@ -13,8 +13,7 @@ export default class Socket {
jitter: 0.25
});
this.handlers = [];
this.connect();
this.connected = false;
}
connect() {
@ -26,12 +25,18 @@ export default class Socket {
}, this.connectTimeout);
this.ws.onopen = () => {
this.connected = true;
this.emit('_connected', true);
clearTimeout(this.timeoutConnect);
this.backoff.reset();
this.setTimeoutPing();
};
this.ws.onclose = () => {
if (this.connected) {
this.connected = false;
this.emit('_connected', false);
}
clearTimeout(this.timeoutConnect);
clearTimeout(this.timeoutPing);
if (!this.closing) {
@ -48,13 +53,14 @@ export default class Socket {
this.retry();
};
this.ws.onmessage = (e) => {
this.ws.onmessage = e => {
this.setTimeoutPing();
const msg = JSON.parse(e.data);
if (msg.type === 'ping') {
this.send('pong');
return;
}
this.emit(msg.type, msg.data);

View File

@ -0,0 +1,120 @@
import React from 'react';
import TestRenderer from 'react-test-renderer';
import { isChannel, isValidNick, isValidChannel, isValidUsername } from '..';
import linkify from '../linkify';
const render = el => TestRenderer.create(el).toJSON();
describe('isChannel()', () => {
it('it handles strings', () => {
expect(isChannel('#cake')).toBe(true);
expect(isChannel('cake')).toBe(false);
});
it('handles tab objects', () => {
expect(isChannel({ name: '#cake' })).toBe(true);
expect(isChannel({ name: 'cake' })).toBe(false);
});
});
describe('isValidNick()', () => {
it('validates nicks', () =>
Object.entries({
bob: true,
'bob likes cake': false,
'-bob': false,
'bob.': false,
'bob-': true,
'1bob': false,
'[bob}': true,
'': false,
' ': false
}).forEach(([input, expected]) =>
expect(isValidNick(input)).toBe(expected)
));
});
describe('isValidChannel()', () => {
it('validates channels', () =>
Object.entries({
'#chan': true,
'#cak e': false,
'#cake:': false,
'#[cake]': true,
'#ca,ke': false,
'': false,
' ': false,
cake: false
}).forEach(([input, expected]) =>
expect(isValidChannel(input)).toBe(expected)
));
it('handles requirePrefix', () =>
Object.entries({
chan: true,
'cak e': false,
'#cake:': false,
'#[cake]': true,
'#ca,ke': false
}).forEach(([input, expected]) =>
expect(isValidChannel(input, false)).toBe(expected)
));
});
describe('isValidUsername()', () => {
it('validates usernames', () =>
Object.entries({
bob: true,
'bob likes cake': false,
'-bob': true,
'bob.': true,
'bob-': true,
'1bob': true,
'[bob}': true,
'': false,
' ': false,
'b@b': false
}).forEach(([input, expected]) =>
expect(isValidUsername(input)).toBe(expected)
));
});
describe('linkify()', () => {
const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href);
const linkTo = href =>
render(
<a href={proto(href)} rel="noopener noreferrer" target="_blank">
{href}
</a>
);
it('returns the arg when no matches are found', () =>
[null, undefined, 10, false, true, 'just some text', ''].forEach(input =>
expect(linkify(input)).toBe(input)
));
it('linkifies text', () =>
Object.entries({
'google.com': linkTo('google.com'),
'google.com stuff': [linkTo('google.com'), ' stuff'],
'cake google.com stuff': ['cake ', linkTo('google.com'), ' stuff'],
'cake google.com stuff https://google.com': [
'cake ',
linkTo('google.com'),
' stuff ',
linkTo('https://google.com')
],
'cake google.com stuff pie https://google.com ': [
'cake ',
linkTo('google.com'),
' stuff pie ',
linkTo('https://google.com'),
' '
],
' google.com': [' ', linkTo('google.com')],
'google.com ': [linkTo('google.com'), ' '],
'/google.com?': ['/', linkTo('google.com'), '?']
}).forEach(([input, expected]) =>
expect(render(linkify(input))).toEqual(expected)
));
});

42
client/js/utils/color.js Normal file
View File

@ -0,0 +1,42 @@
/* eslint-disable no-bitwise */
import { hsluvToHex } from 'hsluv';
//
// github.com/sindresorhus/fnv1a
//
const OFFSET_BASIS_32 = 2166136261;
const fnv1a = string => {
let hash = OFFSET_BASIS_32;
for (let i = 0; i < string.length; i++) {
hash ^= string.charCodeAt(i);
// 32-bit FNV prime: 2**24 + 2**8 + 0x93 = 16777619
// Using bitshift for accuracy and performance. Numbers in JS suck.
hash +=
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
};
const colors = [];
for (let i = 0; i < 72; i++) {
colors[i] = hsluvToHex([i * 5, 40, 50]);
colors[i + 72] = hsluvToHex([i * 5, 70, 50]);
colors[i + 144] = hsluvToHex([i * 5, 100, 50]);
}
const cache = {};
export default function stringToRGB(str) {
if (cache[str]) {
return cache[str];
}
const color = colors[fnv1a(str) % colors.length];
cache[str] = color;
return color;
}

View File

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

View File

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

189
client/js/utils/index.js Normal file
View File

@ -0,0 +1,189 @@
import padStart from 'lodash/padStart';
export { findBreakpoints, messageHeight } from './messageHeight';
export { default as linkify } from './linkify';
export function normalizeChannel(channel) {
if (channel.indexOf('#') !== 0) {
return channel;
}
return channel
.split('#')
.join('')
.toLowerCase();
}
export function isChannel(name) {
// TODO: Handle other channel types
if (typeof name === 'object') {
({ name } = name);
}
return typeof name === 'string' && name[0] === '#';
}
export function stringifyTab(server, name) {
if (typeof server === 'object') {
if (server.name) {
return `${server.server};${server.name}`;
}
return server.server;
}
if (name) {
return `${server};${name}`;
}
return server;
}
function isString(s, maxLength) {
if (!s || typeof s !== 'string') {
return false;
}
if (maxLength && s.length > maxLength) {
return false;
}
return true;
}
// RFC 2812
// nickname = ( letter / special ) *( letter / digit / special / "-" )
// letter = A-Z / a-z
// digit = 0-9
// special = "[", "]", "\", "`", "_", "^", "{", "|", "}"
export function isValidNick(nick, maxLength = 30) {
if (!isString(nick, maxLength)) {
return false;
}
for (let i = 0; i < nick.length; i++) {
const char = nick.charCodeAt(i);
if (
(i > 0 && char < 45) ||
(char > 45 && char < 48) ||
(char > 57 && char < 65) ||
char > 125
) {
return false;
}
if ((i === 0 && char < 65) || char > 125) {
return false;
}
}
return true;
}
// chanstring = any octet except NUL, BELL, CR, LF, " ", "," and ":"
export function isValidChannel(channel, requirePrefix = true) {
if (!isString(channel)) {
return false;
}
if (requirePrefix && channel[0] !== '#') {
return false;
}
for (let i = 0; i < channel.length; i++) {
const char = channel.charCodeAt(i);
if (
char === 0 ||
char === 7 ||
char === 10 ||
char === 13 ||
char === 32 ||
char === 44 ||
char === 58
) {
return false;
}
}
return true;
}
// user = any octet except NUL, CR, LF, " " and "@"
export function isValidUsername(username) {
if (!isString(username)) {
return false;
}
for (let i = 0; i < username.length; i++) {
const char = username.charCodeAt(i);
if (
char === 0 ||
char === 10 ||
char === 13 ||
char === 32 ||
char === 64
) {
return false;
}
}
return true;
}
export function isInt(i, min, max) {
if (i < min || i > max || Math.floor(i) !== i) {
return false;
}
return true;
}
export function timestamp(date = new Date()) {
const h = padStart(date.getHours(), 2, '0');
const m = padStart(date.getMinutes(), 2, '0');
return `${h}:${m}`;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
export function stringWidth(str, font) {
ctx.font = font;
return ctx.measureText(str).width;
}
export function measureScrollBarWidth() {
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.width = '100px';
document.body.appendChild(outer);
const widthNoScroll = outer.offsetWidth;
outer.style.overflow = 'scroll';
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
return widthNoScroll - widthWithScroll;
}
export function findIndex(arr, pred) {
if (!arr) {
return -1;
}
for (let i = 0; i < arr.length; i++) {
if (pred(arr[i])) {
return i;
}
}
return -1;
}
export function find(arr, pred) {
const i = findIndex(arr, pred);
if (i !== -1) {
return arr[i];
}
return null;
}

View File

@ -30,12 +30,20 @@ export default function linkify(text) {
}
result.push(
<a target="_blank" rel="noopener noreferrer" href={match.getAnchorHref()}>
<a
target="_blank"
rel="noopener noreferrer"
href={match.getAnchorHref()}
key={i}
>
{match.matchedText}
</a>
);
} else if (typeof result[result.length - 1] === 'string') {
result[result.length - 1] += text.slice(pos, match.offset + match.matchedText.length);
result[result.length - 1] += text.slice(
pos,
match.offset + match.matchedText.length
);
} else {
result.push(text.slice(pos, match.offset + match.matchedText.length));
}

View File

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

111
client/js/utils/observe.js Normal file
View File

@ -0,0 +1,111 @@
function subscribeArray(store, selectors, handler, init) {
let state = store.getState();
let prev = selectors.map(selector => selector(state));
if (init) {
handler(...prev);
}
return store.subscribe(() => {
state = store.getState();
const next = [];
let changed = false;
for (let i = 0; i < selectors.length; i++) {
next[i] = selectors[i](state);
if (next[i] !== prev[i]) {
changed = true;
}
}
if (changed) {
handler(...next);
prev = next;
}
});
}
function subscribe(store, selector, handler, init) {
if (Array.isArray(selector)) {
return subscribeArray(store, selector, handler, init);
}
let prev = selector(store.getState());
if (init) {
handler(prev);
}
return store.subscribe(() => {
const next = selector(store.getState());
if (next !== prev) {
handler(next);
prev = next;
}
});
}
//
// Handler gets called every time the selector(s) change
//
export function observe(store, selector, handler) {
return subscribe(store, selector, handler, true);
}
//
// Handler gets called once the next time the selector(s) change
//
export function once(store, selector, handler) {
let done = false;
const unsubscribe = subscribe(store, selector, (...args) => {
if (!done) {
done = true;
handler(...args);
}
unsubscribe();
});
}
//
// Handler gets called once when the predicate returns true, the predicate gets passed
// the result of the selector(s), if no predicate is set it defaults to checking if the
// selector(s) return something truthy
//
export function when(store, selector, predicate, handler) {
if (arguments.length === 3) {
handler = predicate;
if (Array.isArray(selector)) {
predicate = (...args) => {
for (let i = 0; i < args.length; i++) {
if (!args[i]) {
return false;
}
}
return true;
};
} else {
predicate = o => o;
}
}
const state = store.getState();
if (Array.isArray(selector)) {
const val = selector.map(s => s(state));
if (predicate(...val)) {
return handler(...val);
}
} else {
const val = selector(state);
if (predicate(val)) {
return handler(val);
}
}
let done = false;
const unsubscribe = subscribe(store, selector, (...args) => {
if (!done && predicate(...args)) {
done = true;
handler(...args);
}
unsubscribe();
});
}

View File

@ -99,7 +99,10 @@ export default function initRouter(routes, store) {
history.listen(location => {
const nextMatch = match(patterns, location);
if (nextMatch && nextMatch.location.pathname !== matched.location.pathname) {
if (
nextMatch &&
nextMatch.location.pathname !== matched.location.pathname
) {
matched = nextMatch;
store.dispatch(matched);
}

45
client/js/utils/size.js Normal file
View File

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

View File

@ -4,60 +4,98 @@
"description": "",
"license": "MIT",
"main": "index.js",
"browserslist": [
"Edge >= 16",
"Firefox >= 60",
"Chrome >= 61",
"Safari >= 10.1",
"iOS >= 10.3"
],
"devDependencies": {
"babel-core": "^6.23.1",
"babel-eslint": "^7.1.1",
"babel-jest": "^20.0.0",
"babel-loader": "^7.0.0",
"babel-plugin-transform-react-constant-elements": "^6.23.0",
"babel-plugin-transform-react-inline-elements": "^6.22.0",
"babel-preset-es2015": "^6.22.0",
"babel-preset-react": "^6.23.0",
"babel-preset-stage-0": "^6.22.0",
"@babel/core": "^7.1.5",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-proposal-export-default-from": "^7.0.0",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
"@babel/plugin-transform-react-inline-elements": "^7.0.0",
"@babel/preset-env": "^7.1.5",
"@babel/preset-react": "^7.0.0",
"babel-core": "^7.0.0-0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"brotli": "^1.3.1",
"css-loader": "^0.28.0",
"eslint": "^3.15.0",
"eslint-config-airbnb": "^14.1.0",
"eslint-loader": "^1.6.1",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jsx-a11y": "^4.0.0",
"eslint-plugin-react": "^6.10.0",
"express": "^4.14.1",
"express-http-proxy": "^1.0.1",
"gulp": "^3.9.1",
"gulp-autoprefixer": "^4.0.0",
"gulp-cached": "^1.1.1",
"gulp-concat": "^2.6.1",
"gulp-cssnano": "^2.1.2",
"css-loader": "^1.0.1",
"cssnano": "^4.1.7",
"del": "^3.0.0",
"eslint": "^5.8.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^3.1.0",
"eslint-import-resolver-webpack": "^0.10.1",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-react": "^7.11.1",
"express": "^4.16.4",
"express-http-proxy": "^1.5.0",
"gulp": "4.0.0",
"gulp-util": "^3.0.8",
"jest": "^20.0.0",
"style-loader": "^0.17.0",
"through2": "^2.0.3",
"webpack": "^2.4.1",
"webpack-dev-middleware": "^1.10.0",
"webpack-hot-middleware": "^2.17.0"
"jest": "^23.6.0",
"mini-css-extract-plugin": "^0.4.4",
"postcss-flexbugs-fixes": "^4.1.0",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.4.0",
"prettier": "1.15.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"
},
"dependencies": {
"autolinker": "^1.4.3",
"autolinker": "^1.7.1",
"backo": "^1.1.0",
"base64-arraybuffer": "^0.1.5",
"classnames": "^2.2.6",
"fontfaceobserver": "^2.0.9",
"formik": "^1.3.1",
"history": "4.5.1",
"immutable": "^3.8.1",
"hsluv": "^0.0.3",
"immer": "^1.7.3",
"js-cookie": "^2.1.4",
"lodash": "^4.17.4",
"react": "^15.4.2",
"react-dom": "^15.4.2",
"react-hot-loader": "next",
"react-redux": "^5.0.2",
"react-virtualized": "^9.3.0",
"redux": "^3.6.0",
"redux-thunk": "^2.2.0",
"reselect": "^3.0.0",
"lodash": "^4.17.11",
"react": "^16.7.0-alpha.0",
"react-dom": "^16.7.0-alpha.0",
"react-hot-loader": "^4.4.0",
"react-redux": "^6.0.0-beta.2",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.2.2",
"redux": "^4.0.1",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"url-pattern": "^1.0.3"
},
"scripts": {
"prettier": "prettier --write {.*,*.js,css/*.css,**/*.test.js}",
"prettier:all": "prettier --write {.*,*.js,**/*.js,css/*.css}",
"test": "jest",
"test:watch": "npm test -- --watch"
"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:binary": "gencode go -package storage -schema ../storage/storage.schema -unsafe",
"gen:json": "easyjson -all -lower_camel_case -omit_empty ../server/json.go ../server/index_data.go && easyjson -lower_camel_case -omit_empty ../storage/user.go",
"gen:template": "egon -s -m ../server"
},
"jest": {
"moduleNameMapper": {
"^components(.*)$": "<rootDir>/js/components$1",
"^containers(.*)$": "<rootDir>/js/containers$1",
"^state(.*)$": "<rootDir>/js/state$1",
"^utils(.*)$": "<rootDir>/js/utils$1"
}
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

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