174 Commits

Author SHA1 Message Date
idk
8f18eaa4cf Remove debugging message 2020-07-29 23:22:17 -04:00
idk
f997149dd6 how awesome is that! 2020-07-29 23:04:13 -04:00
idk
a02ad3a299 try it with goSam 2020-07-29 22:28:34 -04:00
ea4f321fe7 Collapse nick changes 2020-07-05 08:32:29 +02:00
1fe4c4d17e Format topic 2020-06-30 13:28:51 +02:00
307573830a Render text blocks 2020-06-30 13:24:23 +02:00
ca4db66308 Add __Host- prefix, set X-XSS-Protection to 0, require go1.11 2020-06-25 08:35:15 +02:00
d844f6ee1a Combine init actions 2020-06-25 01:50:10 +02:00
02e9df865e Show DCC send filesize 2020-06-24 08:09:05 +02:00
45f8795fad Stop trimming SASL and server passwords 2020-06-24 07:46:13 +02:00
e0ca9d5d8c Fix lastMessages map init in LoadUsers() 2020-06-22 17:22:10 +02:00
972f568a00 Fix bad nick test 2020-06-21 06:07:32 +02:00
b9f52a8761 Select channel when joining through UI 2020-06-21 05:33:02 +02:00
ca23b3ded8 Improve error routing, fix dm tab not opening 2020-06-21 05:13:38 +02:00
1c996822cd Style scrollbars 2020-06-20 07:41:01 +02:00
f89e6ae133 Darken hashtags in channel list 2020-06-19 04:38:26 +02:00
4694e66e98 Ignore hashtags when sorting channels 2020-06-19 03:58:08 +02:00
fc937aaac8 Update client dependencies 2020-06-19 03:02:42 +02:00
fd5e50a2cb Add DescribeTLS() 2020-06-18 09:01:29 +02:00
04e6e8c7a2 Bind identd to config address, set read/write deadlines in ident.Server, only reply to ident queries where the remote hosts match 2020-06-17 03:19:20 +02:00
15ee5ce1c9 Use small buffer for ident query 2020-06-16 12:01:07 +02:00
e0d2243248 Add identd 2020-06-16 11:28:47 +02:00
67fe5d263d Add SOCKS5 proxy support 2020-06-16 03:04:27 +02:00
7040f1c8d0 Use mutex pointer in network state 2020-06-16 01:29:35 +02:00
2ea4584c97 Add auto_ctcp config option 2020-06-16 01:22:23 +02:00
fcf0c17682 Update dependencies 2020-06-15 11:05:32 +02:00
6985dd16da Handle kick, rename server to network 2020-06-15 10:58:51 +02:00
a33157ff84 Return false from socket handlers to not dispatch action 2020-06-05 08:56:11 +02:00
a4fe18c0f0 Update client dependencies 2020-06-04 08:09:59 +02:00
9d8d04fa7c Filter SASL mechanisms on RPL_SASLMECHS without trying next one 2020-06-04 05:06:59 +02:00
876d9ebdd0 Implement SCRAM-SHA-256 2020-06-04 02:28:41 +02:00
ead3b37cf9 Collapse and log join, part and quit, closes #27, log nick and topic changes, move state into irc package 2020-06-03 03:04:38 +02:00
edd4d6eadb Disable cgo in install.sh 2020-05-25 01:25:05 +02:00
e76beca4a0 Parse ident and host, rename irc.Message.Nick to Sender 2020-05-24 11:09:59 +02:00
8829793290 Append clientWantedCaps 2020-05-24 07:22:13 +02:00
71b2136a9e Update dependencies 2020-05-24 07:21:47 +02:00
e97c7f2ada Pass config struct into irc.Client 2020-05-23 09:42:20 +02:00
9aac4f4e29 Update README 2020-05-23 08:28:32 +02:00
2f8dad2529 Add SASL auth and CAP negotiation 2020-05-23 08:05:37 +02:00
be8b785813 Scrape horse doc constants, handle all nick collision error types 2020-05-23 02:30:48 +02:00
e937f5d8b9 Quit on ERROR 2020-05-22 01:22:08 +02:00
99b3ff519b Remember last used nick and realname, closes #43 2020-05-21 05:57:05 +02:00
35727fb2b8 Fix dm tab being opened on server messages 2020-05-21 05:24:26 +02:00
1abe280957 Set timeout and deadline in DownloadDCC 2020-05-20 08:38:18 +02:00
e33b9f05e4 Implement DCC streaming 2020-05-20 07:21:12 +02:00
973578bb49 Handle common CTCP messages 2020-05-20 04:19:40 +02:00
4816fbfbca Fetch scrollback messages in dm tabs 2020-05-20 00:30:44 +02:00
f0eada0f75 Remove openDMs when deleting user 2020-05-20 00:23:16 +02:00
abbe739b04 Update README 2020-05-19 10:48:54 +02:00
63afd839be Merge pull request #62 from pidario/feat/dcc-support
start of DCC implementation
2020-05-19 10:44:07 +02:00
84a10efe36 Log key dcc info messages, keep dcc tab open 2020-05-19 10:37:20 +02:00
902e4da46f Remove fontfaceobserver 2020-05-19 01:29:22 +02:00
e05118a29b Move script tags 2020-05-18 11:53:29 +02:00
a90e8d4b2f Improve speed calculation, clean some things up 2020-05-18 03:32:57 +02:00
2d68f04ab2 Load client cert with LoadX509KeyPair 2020-05-18 00:36:15 +02:00
b92f5cfb43 Send download link on completion 2020-05-17 06:34:59 +02:00
fa99a96733 Create download directory in NewUser 2020-05-17 02:31:50 +02:00
6b5bf4ced1 Add per-user download directory 2020-05-17 02:14:35 +02:00
0c902f8ac8 Render IRC colors and formatting, closes #46 2020-05-16 08:25:58 +02:00
ed432881ef Push connect chunks 2020-05-16 03:58:17 +02:00
75c9560dfb Handle nil and different types in stateData 2020-05-16 02:33:38 +02:00
1532b2a8c8 changed sendJson param and removed not needed file mode
removed instantaneous speed; update only if percentage delta > 0.1

removed instantaneous speed; update only if percentage delta > 0.1
2020-05-12 16:01:17 +02:00
4b8491cf99 Make TextInput label real, add aria-label to icon buttons 2020-05-12 07:48:12 +02:00
2509420ba5 Make ToCTCP handle any type of CTCP message, move DCC handling to separate file 2020-05-12 04:13:05 +02:00
ed2e56948e start of DCC implementation
(server side)

small refactoring and added speed calculation

fixup! small refactoring and added speed calculation

download progress

fixup! download progress
2020-05-11 17:34:37 +02:00
9581a63e81 Ignore empty channel lists 2020-05-11 14:13:36 +02:00
8fa91ac470 Use string port 2020-05-10 02:53:39 +02:00
eb7545455c Add jsconfig.json 2020-05-10 01:35:58 +02:00
eab788a782 Enable overriding connect defaults with query params, closes #49 2020-05-08 10:26:32 +02:00
dcbf3397c1 Use latest node on travis 2020-05-08 04:16:41 +02:00
1773fef8ef Update gulpfile 2020-05-08 04:16:23 +02:00
08ffc79a65 Remove local messages when closing dm tab, avoid sending open_dm if already open 2020-05-08 02:56:54 +02:00
2a72b198e3 Update dependencies 2020-05-08 02:10:22 +02:00
0d085a2b4d Clear prevSearch when closing modal 2020-05-07 08:10:25 +02:00
497f9e882c Unvendor fnv1a 2020-05-06 06:50:53 +02:00
7d97d10e76 Open dm tab on /msg 2020-05-06 04:50:27 +02:00
8305dd561d Log direct messages and keep track of open direct message tabs 2020-05-06 04:19:55 +02:00
e97bb519ed Merge pull request #61 from nhandler/patch-1
Replace Freenode with freenode
2020-05-05 07:26:38 +02:00
18acde5b2b Replace Freenode with freenode in test
The freenode IRC network brands itself with a lowercase `f`.
2020-05-04 21:37:39 -07:00
bab4732221 Replace Freenode with freenode in default config
The freenode IRC network brands itself with a lowercase `f`.
2020-05-04 21:36:21 -07:00
79af695d17 Add apple-touch-icon 2020-05-05 03:34:36 +02:00
d98312f99b Cache manifest.json 2020-05-05 03:31:14 +02:00
010bb6a102 Sleep before first reconnect attempt 2020-05-05 01:35:05 +02:00
530e08b9ee Convert withModal to useModal 2020-05-03 09:05:16 +02:00
9cf42df1ea Remove channel from reconnect list when parting 2020-05-01 05:40:49 +02:00
b81e1e482a Add auth config struct and restructure social auth provider config to enable iteration and adding other providers 2020-05-01 04:35:26 +02:00
3d7011e504 Add vscode to config command editor stack and enable passing in an arbitrary editor 2020-05-01 04:31:20 +02:00
3d2e443108 Pass all unknown commands through to the IRC server, closes #56 2020-05-01 02:12:21 +02:00
b002eef285 Fix navicon 2020-04-30 14:36:30 +02:00
c566d5d61d Copy location object 2020-04-30 13:44:36 +02:00
13a31c30d9 Update .goreleaser.yml 2020-04-30 08:16:06 +02:00
508a41ee45 Update client dependencies 2020-04-30 07:54:30 +02:00
1794e2680a Update server dependencies 2020-04-29 04:23:32 +02:00
c704ebb042 Use react-icons 2020-04-29 03:13:35 +02:00
bb66740fd1 Update gulp instructions in README 2020-04-24 03:04:46 +02:00
4010132884 Linkify topics in channel joining modal 2020-04-24 02:37:56 +02:00
77543e3aed Switch to bbolt 2020-04-23 01:06:36 +02:00
360bed00f9 Implement old storage.Path API 2020-04-20 03:02:15 +02:00
164e071e7f Update README 2020-04-20 02:15:57 +02:00
01914f070d Turn modules off when installing go-bindata on travis 2020-04-20 02:07:12 +02:00
00e40dc153 Update go.mod and modules.txt format 2020-04-20 01:38:06 +02:00
47efab2e56 Update .travis.yml 2020-04-20 01:27:25 +02:00
c171a620e0 Merge pull request #58 from daftaupe/configpath
Allow dispatch to store data and configuration separately
2020-04-19 20:30:34 +02:00
ca81475fa5 Add option --config and --data to specify where to store the configuration and the data 2020-03-20 18:24:17 +01:00
52b2b6677f Merge pull request #50 from khlieng/dependabot/npm_and_yarn/client/eslint-utils-1.4.3
Bump eslint-utils from 1.3.1 to 1.4.3 in /client
2019-10-31 22:37:03 +01:00
5013ab6db1 Bump eslint-utils from 1.3.1 to 1.4.3 in /client
Bumps [eslint-utils](https://github.com/mysticatea/eslint-utils) from 1.3.1 to 1.4.3.
- [Release notes](https://github.com/mysticatea/eslint-utils/releases)
- [Commits](https://github.com/mysticatea/eslint-utils/compare/v1.3.1...v1.4.3)

Signed-off-by: dependabot[bot] <support@github.com>
2019-10-31 10:55:29 +00:00
855f4d3e64 Stop using pointers for nested config structs, closes #41 2019-06-09 02:29:35 +02:00
540efa03c4 Update dependencies 2019-06-09 02:01:48 +02:00
7ad76273c0 Fix MessageBox scroll stutter when state updates while close to the bottom 2019-02-08 09:28:55 +01:00
c1e1f2c327 Update dependencies 2019-02-08 09:10:06 +01:00
4eda7ef396 Fix getSortedChannels test 2019-02-08 08:56:21 +01:00
fad2e030d4 Fix h2 push hash check 2019-02-01 06:39:38 +01:00
3e90e6c86d Only count joined channels 2019-01-30 04:48:37 +01:00
71bfe92dae Update dependencies 2019-01-30 03:51:13 +01:00
613d9fca6e Send irc features to the client 2019-01-27 08:53:07 +01:00
9267c661dc Timeout channel list updates 2019-01-25 11:24:57 +01:00
f8e12f5938 Handle channel forwarding and errors better 2019-01-25 11:02:31 +01:00
497934888c Fix channel reducer tests 2019-01-25 08:36:21 +01:00
24960f23b9 Add topic modal 2019-01-25 03:57:58 +01:00
aab1ad3e99 Fix h2 push 2019-01-25 02:32:22 +01:00
815b518c2c Update dependencies 2019-01-23 08:52:17 +01:00
5e674254f0 Use pointer receiver in stateData 2019-01-23 08:20:16 +01:00
24b26aa85f Add channel joining UI, closes #37 2019-01-23 07:34:39 +01:00
f25594e962 Add casefolding to irc lib 2019-01-13 05:10:11 +01:00
075e404079 Reset backoff on RPL_WELCOME 2019-01-11 05:00:15 +01:00
eee260f154 Read lines with a bufio.Scanner, reuse input buffer, ignore empty messages, handle multiple spaces between tags and prefix 2019-01-11 04:53:50 +01:00
a3618b97ae Add list command 2019-01-11 02:46:46 +01:00
e4d5d2737b Use strings.Replacer to unescape tags 2019-01-11 02:19:57 +01:00
0085cea5a1 Add react-modal, replace confirm usage with it 2019-01-05 07:08:34 +01:00
63cf65100d Pull https handling out into a new package 2018-12-31 03:33:05 +01:00
67e32661f1 Update dependencies 2018-12-31 02:20:22 +01:00
95eff71e2e Add go 1.12beta1 travis build 2018-12-21 01:53:35 +01:00
8526805c2f Add headers config, closes #25 2018-12-20 11:51:31 +01:00
6aaa2b521d Avoid sending join when the channels input is empty 2018-12-19 03:09:13 +01:00
0d9290d037 Update client dependencies 2018-12-19 02:55:50 +01:00
6fedb23363 Prerender index page 2018-12-17 14:44:46 +01:00
fc643483be Dont redirect private IPs and localhost 2018-12-17 12:45:33 +01:00
6c3a5777c4 Use certmagic, simplify config, set HTTP timeouts and a modern TLSConfig 2018-12-16 12:32:03 +01:00
c5a9a5b1c1 Print go version 2018-12-15 11:30:29 +01:00
6a816fbff6 Add date marker tests 2018-12-15 11:20:49 +01:00
3c105c493b Handle messages with no content, improve prepend perf 2018-12-15 11:09:57 +01:00
50d735aaa3 Add date markers 2018-12-14 14:24:23 +01:00
34d89c75b2 Use correct gulp version on travis 2018-12-11 11:17:05 +01:00
71f79fd84e Pass in config struct 2018-12-11 10:54:05 +01:00
8f1105bc59 Only attempt to maintain scroll position when prepending messages if there was previously any messages 2018-12-08 11:56:02 +01:00
0e46fbcc82 Clean up initialState module 2018-12-08 11:25:08 +01:00
c1ca29511e Simplify routing logic 2018-12-08 11:08:01 +01:00
35c2d682e3 Improve routing 2018-12-07 08:48:40 +01:00
aca380629f Update dotfiles 2018-12-06 11:05:10 +01:00
007fc80e72 Update dependencies 2018-12-06 11:03:03 +01:00
cca3f5bc93 Use stable readme download links 2018-12-02 11:31:35 +01:00
f05e405fb1 Embed version info in docker build 2018-12-02 07:39:23 +01:00
73205b64f6 v0.5.4 2018-11-29 13:07:27 +01:00
5861a54dfc Improve 404 handling 2018-11-29 13:06:37 +01:00
869713d236 Fix initial scroll position sometimes being off 2018-11-29 12:05:42 +01:00
0438a099cf Fix dev mode, turn off react concurrent mode, update dependencies 2018-11-29 11:54:05 +01:00
df71c54d37 Use a map to serve files 2018-11-27 12:07:48 +01:00
d24d33d94c Fix push cookie hash check 2018-11-27 11:34:02 +01:00
57704c9e89 v0.5.3 2018-11-26 02:09:29 +01:00
dd03e75be4 Add amd64 and darwin archive name replacements 2018-11-26 02:07:41 +01:00
4eac0609c2 Add bottom margin to settings version 2018-11-26 02:05:21 +01:00
bf2e7875a5 Add version flags and command, closes #38 2018-11-26 01:58:24 +01:00
fbbcf6457e Show version info in settings 2018-11-22 12:08:18 +01:00
5d896ae439 Print prettier version info 2018-11-22 11:31:02 +01:00
9b40934654 Grab referer in /init handler instead of getIndexData() 2018-11-22 10:40:29 +01:00
bd0541120f Improve document.title handling 2018-11-22 10:09:13 +01:00
09a1933131 Increase scrollbackDebounce to 150 2018-11-22 09:51:26 +01:00
8eb1f9cbb4 Fix some messages not rendering on MessageBox mount, handle scroll restoration edge case 2018-11-22 09:50:38 +01:00
9ca50b1546 v0.5.2 2018-11-22 08:48:29 +01:00
0699fc287d Fix scroll jumping on MessageBox mount 2018-11-22 08:48:29 +01:00
937480bfd4 Prevent rounded input corners in Safari 2018-11-22 08:48:29 +01:00
05527e41b7 Use absolute paths 2018-11-22 08:48:29 +01:00
65e103fd23 Delete release.sh 2018-11-21 02:17:51 +01:00
2a7b7572fa Update readme download links 2018-11-17 12:48:15 +01:00
1526 changed files with 226082 additions and 123346 deletions

View File

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

1
.gitignore vendored
View File

@ -3,4 +3,3 @@ dispatch
client/dist
client/node_modules
client/yarn-error.log
ca-certificates.crt

View File

@ -1,6 +1,6 @@
builds:
- ldflags:
- -s -w -X github.com/khlieng/dispatch/commands.version=v{{.Version}} -X github.com/khlieng/dispatch/commands.commit={{.ShortCommit}} -X github.com/khlieng/dispatch/commands.date={{.Date}}
- -s -w -X github.com/khlieng/dispatch/version.Tag=v{{.Version}} -X github.com/khlieng/dispatch/version.Commit={{.ShortCommit}} -X github.com/khlieng/dispatch/version.Date={{.Date}}
env:
- CGO_ENABLED=0
@ -19,13 +19,17 @@ builds:
- 6
- 7
archive:
files:
- none*
archives:
- files:
- none*
format_overrides:
- goos: windows
format: zip
format_overrides:
- goos: windows
format: zip
replacements:
amd64: x64
darwin: mac
checksum:
name_template: "checksums.txt"

View File

@ -13,12 +13,12 @@ matrix:
- go: tip
install:
- go get github.com/jteeuwen/go-bindata/...
- GO111MODULE=off go get github.com/jteeuwen/go-bindata/...
- cd client
- nvm install 11.2.0
- nvm use 11.2.0
- nvm install node
- nvm use node
- npm install -g yarn
- yarn global add gulp@next
- yarn global add gulp-cli
- yarn
script:

3
.vscode/settings.json vendored Normal file
View File

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

View File

@ -6,7 +6,7 @@ RUN apk add --update git make build-base && \
WORKDIR /go/src/github.com/khlieng/dispatch
COPY . /go/src/github.com/khlieng/dispatch
RUN go build .
RUN chmod +x install.sh && ./install.sh
# Runtime
FROM alpine
@ -14,7 +14,7 @@ FROM alpine
RUN apk add --update ca-certificates && \
rm -rf /var/cache/apk/*
COPY --from=build /go/src/github.com/khlieng/dispatch/dispatch /dispatch
COPY --from=build /go/bin/dispatch /dispatch
EXPOSE 80/tcp

View File

@ -10,6 +10,9 @@
- Persistent connections
- Multiple servers and users
- Automatic HTTPS through Let's Encrypt
- Single binary with no dependencies
- DCC downloads
- SASL
- Client certificates
## Usage
@ -18,14 +21,14 @@ There is a few different ways of getting it:
### 1. Binary
- **[Windows (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5.1/dispatch_windows_amd64.zip)**
- **[macOS (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5.1/dispatch_darwin_amd64.zip)**
- **[Linux (x64)](https://github.com/khlieng/dispatch/releases/download/v0.5.1/dispatch_linux_amd64.tar.gz)**
- **[Windows (x64)](https://release.khlieng.com/khlieng/dispatch/windows_x64)**
- **[macOS (x64)](https://release.khlieng.com/khlieng/dispatch/mac_x64)**
- **[Linux (x64)](https://release.khlieng.com/khlieng/dispatch/linux_x64)**
- [Other versions](https://github.com/khlieng/dispatch/releases)
### 2. Go
This requires a [Go environment](http://golang.org/doc/install), version 1.10 or greater.
This requires a [Go environment](http://golang.org/doc/install), version 1.11 or greater.
Fetch, compile and run dispatch:
@ -51,7 +54,6 @@ docker run -p <http port>:80 -p <https port>:443 -v <path>:/data khlieng/dispatc
### Server
```bash
cd $GOPATH/src/github.com/khlieng/dispatch
go install
```
@ -62,9 +64,9 @@ This requires [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com).
Fetch the dependencies:
```bash
go get github.com/jteeuwen/go-bindata/...
yarn global add gulp@next
cd $GOPATH/src/github.com/khlieng/dispatch/client
GO111MODULE=off go get github.com/jteeuwen/go-bindata/...
yarn global add gulp-cli
cd client
yarn
```
@ -94,11 +96,11 @@ The libraries this project is built with.
### Server
- [Bolt](https://github.com/boltdb/bolt)
- [Bolt](https://github.com/etcd-io/bbolt)
- [Bleve](https://github.com/blevesearch/bleve)
- [Cobra](https://github.com/spf13/cobra)
- [Viper](https://github.com/spf13/viper)
- [Lego](https://github.com/xenolf/lego)
- [CertMagic](https://github.com/mholt/certmagic)
### Client

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,7 @@
"env": {
"browser": true
},
"plugins": ["babel"],
"rules": {
"consistent-return": 0,
"jsx-a11y/click-events-have-key-events": 0,
@ -13,9 +14,16 @@
"no-param-reassign": 0,
"no-plusplus": 0,
"no-restricted-globals": 1,
"no-underscore-dangle": 1,
"react/destructuring-assignment": 0,
"react/jsx-filename-extension": 0,
"react/prop-types": 0
"react/jsx-props-no-spreading": 0,
"react/prop-types": 0,
"react/state-in-constructor": 0,
"react/static-property-placement": 0,
"no-unused-expressions": 0,
"babel/no-unused-expressions": 2
},
"settings": {
"import/resolver": {

View File

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

View File

@ -13,12 +13,10 @@ module.exports = {
['@babel/plugin-proposal-class-properties', { loose: true }],
'@babel/plugin-proposal-export-default-from',
'@babel/plugin-proposal-export-namespace-from',
'@babel/plugin-syntax-dynamic-import'
'@babel/plugin-syntax-dynamic-import',
'react-hot-loader/babel'
],
env: {
development: {
plugins: ['react-hot-loader/babel']
},
test: {
plugins: ['@babel/plugin-transform-modules-commonjs']
},

View File

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

View File

@ -2,12 +2,34 @@
margin: 0;
padding: 0;
box-sizing: border-box;
scrollbar-width: thin;
}
body {
font-family: Roboto Mono, monospace;
background: #f0f0f0;
color: #222;
scrollbar-color: #ccc rgba(0, 0, 0, 0);
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0);
}
::-webkit-scrollbar-thumb {
background: #ccc;
}
::-webkit-scrollbar-thumb:hover {
background: #999;
}
::-webkit-scrollbar-thumb:active {
background: #666;
}
h1,
@ -19,6 +41,15 @@ h6 {
font-family: Montserrat, sans-serif;
}
a {
text-decoration: none;
color: #0066ff;
}
a:hover {
text-decoration: underline;
}
input {
font: 16px Roboto Mono, monospace;
border: none;
@ -31,7 +62,16 @@ input::-ms-clear {
display: none;
}
input,
textarea {
border-radius: 0;
appearance: none;
}
button {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
height: 50px;
background: #6bb758;
@ -51,6 +91,33 @@ button:active {
background: #6bb758;
}
.button-normal {
background: #222;
}
.button-normal:hover {
background: #111;
}
.button-normal:active {
background: #222;
}
.icon-button {
background: none;
width: 40px;
color: #222;
font-size: 20px;
}
.icon-button:hover {
background: none;
}
.icon-button:active {
background: none;
}
label {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
@ -122,6 +189,10 @@ i[class*=' icon-']:before {
color: #f6546a !important;
}
.disabled {
color: #999 !important;
}
.textinput {
display: block;
position: relative;
@ -131,7 +202,7 @@ i[class*=' icon-']:before {
padding: 25px 15px 10px;
}
.textinput span {
.textinput-label {
position: absolute;
top: 0;
left: 0;
@ -212,6 +283,19 @@ i[class*=' icon-']:before {
font-family: Montserrat, sans-serif;
transition: transform 0.2s;
user-select: none;
scrollbar-color: #333 rgba(0, 0, 0, 0);
}
.tablist ::-webkit-scrollbar-thumb {
background: #333;
}
.tablist ::-webkit-scrollbar-thumb:hover {
background: #444;
}
.tablist ::-webkit-scrollbar-thumb:active {
background: #555;
}
.tab-container {
@ -219,7 +303,8 @@ i[class*=' icon-']:before {
top: 0;
bottom: 50px;
width: 100%;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
}
.tablist p {
@ -227,6 +312,9 @@ i[class*=' icon-']:before {
padding: 3px 15px;
padding-right: 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tablist p:last-child {
@ -242,30 +330,54 @@ i[class*=' icon-']:before {
border-left: 5px solid #6bb758;
}
.tab-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-server {
.tab-network {
display: flex;
align-items: center;
color: #999;
margin-top: 10px !important;
}
.tab-server .tab-content {
.tab-network .tab-content {
flex: 1;
margin-right: 5px;
}
.tab-prefix {
color: #999;
}
.error .tab-prefix {
color: #f6546a;
}
.tab-label {
margin-top: 10px;
margin: 5px;
margin-left: 15px;
margin-bottom: 5px;
font-size: 12px;
color: #999;
display: flex;
align-items: center;
height: 25px;
}
.tab-label-channels {
cursor: pointer;
}
.tab-label span {
flex: 1;
}
.tab-label button {
width: 24px;
height: 100%;
font-size: 20px;
background: none;
color: #999;
}
.tab-label:hover button {
color: #ccc;
}
.side-buttons {
@ -278,20 +390,13 @@ i[class*=' icon-']:before {
border-top: 1px solid #1d1d1d;
}
.side-buttons i {
flex: 100%;
color: #999;
line-height: 50px;
cursor: pointer;
font-size: 18px;
border-left: 1px solid #1d1d1d;
}
.side-buttons button {
font-size: 24px;
background: #222;
color: #999;
flex: 1;
}
.side-buttons i:hover {
.side-buttons button:hover {
color: #ccc;
background: #1d1d1d;
}
@ -325,7 +430,7 @@ i[class*=' icon-']:before {
.connect-form {
margin: auto 20px;
padding-top: 20px;
padding: 20px 0;
width: 350px;
text-align: center;
}
@ -350,6 +455,17 @@ i[class*=' icon-']:before {
width: 100%;
}
.connect-section {
border-bottom: 1px solid #ddd;
padding-bottom: 15px;
margin: 15px 0;
margin-bottom: 10px;
}
.connect-section h2 {
margin-bottom: 10px;
}
input[type='number'] {
appearance: textfield;
}
@ -362,11 +478,6 @@ input::-webkit-inner-spin-button {
.connect-form label {
user-select: none;
cursor: default;
}
.connect-form button {
margin-bottom: 20px;
}
.connect-form-address {
@ -388,23 +499,25 @@ input::-webkit-inner-spin-button {
.connect-form-address label {
margin-top: 5px;
font: 12px 'Montserrat', sans-serif;
padding: 10px;
padding: 10px 0;
padding-bottom: 0;
text-align: center;
background: #fff;
color: #777;
}
.connect-form i {
display: block;
cursor: pointer;
color: #999;
text-align: center;
font-size: 24px;
padding: 5px 0;
.connect-form-ssl {
padding: 10px !important;
}
.connect-form i:hover {
.connect-form-button-optionals {
font-size: 24px;
color: #999;
height: 40px;
width: 100%;
}
.connect-form-button-optionals:hover {
color: #666;
}
@ -419,6 +532,7 @@ input::-webkit-inner-spin-button {
border-bottom: 1px solid #ddd;
display: flex;
font-size: 20px;
padding-right: 5px;
}
.chat-channel .chat-title-bar {
@ -427,63 +541,55 @@ input::-webkit-inner-spin-button {
.navicon {
display: none;
padding: 0 15px;
line-height: 50px;
font-size: 20px;
width: 50px;
cursor: pointer;
}
.chat-title-bar i {
padding: 0 15px;
cursor: pointer;
}
.chat-server .icon-search {
display: none;
}
.chat-server .userlist,
.chat-network .userlist,
.chat-private .userlist {
display: none;
}
.chat-server .userlist-bar,
.chat-network .userlist-bar,
.chat-private .userlist-bar {
display: none;
}
.button-leave {
border-left: 1px solid #ddd;
.chat-title-bar .icon-button:not(.navicon) {
color: #999;
}
.button-leave:hover {
background: #ddd;
.chat-title-bar .icon-button:hover {
color: #222;
}
.button-userlist {
display: none;
border-left: 1px solid #ddd;
}
.chat-server .button-userlist,
.chat-network .button-userlist,
.chat-private .button-userlist {
display: none;
}
.editable-wrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.editable-wrap-editable {
cursor: pointer;
}
.chat-title {
margin-left: 10px;
padding: 0 5px;
margin-left: 15px;
font: 24px Montserrat, sans-serif;
font-weight: 700;
color: #222;
white-space: nowrap;
line-height: 50px;
}
.chat-server .chat-title {
cursor: pointer;
}
input.chat-title {
background: none;
cursor: text !important;
@ -492,7 +598,8 @@ input.chat-title {
.chat-topic-wrap {
flex: 1;
position: relative;
margin: 0 15px;
margin-left: 15px;
margin-right: 5px;
}
.chat-topic {
@ -501,21 +608,16 @@ input.chat-title {
top: 3px;
font-size: 16px;
color: #999;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-topic a {
color: #999;
text-decoration: none;
}
.chat-topic a:hover {
text-decoration: underline;
}
.userlist-bar {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
right: 0;
@ -523,14 +625,11 @@ input.chat-title {
height: 50px;
border-left: 1px solid #ddd;
border-bottom: 1px solid #ddd;
line-height: 50px;
text-align: center;
padding: 0 15px;
font-family: Montserrat, sans-serif;
}
.userlist-bar i {
margin-right: 3px;
.userlist-bar svg {
margin-right: 5px;
}
.search {
@ -544,7 +643,7 @@ input.chat-title {
background: #f0f0f0;
}
.chat-server .search {
.chat-network .search {
display: none;
}
@ -559,17 +658,19 @@ input.chat-title {
border-bottom: 1px solid #ddd;
}
.search i {
padding: 15px;
color: #ddd;
}
.search-input {
flex: 1;
padding: 15px;
padding-left: 0;
}
.search-input-icon {
font-size: 20px;
align-self: center;
margin: 0 15px;
color: #ddd;
}
.search-results {
position: absolute;
top: 50px;
@ -589,8 +690,6 @@ input.chat-title {
top: 50px;
bottom: 50px;
right: 0;
z-index: 1;
overflow: hidden;
}
.chat-channel .messagebox {
@ -609,6 +708,24 @@ input.chat-title {
overflow-y: scroll !important;
}
.messagebox-topdate-container {
position: absolute;
text-align: center;
left: 0;
height: 0;
}
.messagebox-topdate {
position: relative;
top: -12px;
background: #f0f0f0;
color: #999;
border-radius: 50vh;
padding: 0 5px;
font-size: 12px;
z-index: 2;
}
.message {
padding: 4px 15px;
}
@ -631,6 +748,18 @@ input.chat-title {
color: #ff6698;
}
.message-date {
text-align: center;
color: #999;
font-size: 12px;
margin-top: 12px;
}
.message-date hr {
border: none;
border-bottom: 1px solid #ddd;
}
.message-time {
font-style: normal;
font-weight: 400;
@ -643,13 +772,9 @@ input.chat-title {
cursor: pointer;
}
.message a {
text-decoration: none;
color: #0066ff;
}
.message a:hover {
text-decoration: underline;
.message-events-more {
font-weight: 700;
cursor: pointer;
}
.message-input-wrap {
@ -689,7 +814,7 @@ input.message-input-nick.invalid {
flex: 1;
width: 100%;
height: 100%;
padding: 0 15px;
padding: 0 10px;
}
.userlist {
@ -700,7 +825,7 @@ input.message-input-nick.invalid {
width: 200px;
border-left: 1px solid #ddd;
background: #f0f0f0;
z-index: 2;
z-index: 1;
transition: transform 0.2s;
}
@ -744,11 +869,10 @@ input.message-input-nick.invalid {
}
.settings h2 {
font-weight: 700;
color: #222;
}
.settings button {
.settings-button {
width: 200px;
}
@ -792,8 +916,8 @@ input.message-input-nick.invalid {
margin-right: 0;
}
.settings-button {
margin-top: 10px;
.settings-file:last-of-type {
margin-bottom: 10px;
}
}
@ -806,15 +930,187 @@ input.message-input-nick.invalid {
text-align: center;
}
.settings-version {
color: #999;
font-size: 12px;
margin-bottom: 20px;
}
.suspense-fallback {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font: 700 64px 'Montserrat', sans-serif;
height: 100%;
color: #ddd;
}
.suspense-modal-fallback {
position: fixed;
right: 15px;
bottom: 3px;
z-index: 1;
font: 700 64px 'Montserrat', sans-serif;
color: #ddd;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.33);
opacity: 0;
transition: opacity 0.2s;
}
.modal-overlay-opening {
opacity: 1;
}
.modal-overlay-closing {
opacity: 0;
}
.modal {
width: 600px;
min-width: 0;
padding: 15px;
background: #f0f0f0;
border: 1px solid #ddd;
outline: none;
margin: 15px;
text-align: center;
font-family: 'Montserrat', sans-serif;
transform: translateY(-20px);
transition: transform 0.2s;
}
.modal-opening {
transform: translateY(0);
}
.modal-closing {
transform: translateY(-20px);
}
.modal p {
margin-bottom: 5px;
}
.modal button {
width: 120px;
}
.modal button {
margin: 0 5px;
margin-top: 10px;
}
.modal-header {
display: flex;
align-items: center;
}
.modal-header h2 {
flex: 1;
}
.modal-close {
color: #999;
cursor: pointer;
width: auto !important;
height: auto;
margin: 0 !important;
}
.modal-close:hover {
color: #222;
}
.modal-content {
margin-top: 15px;
}
.modal-channel {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
padding: 0;
}
.modal-channel input {
flex: 1;
padding: 15px;
}
.modal-channel-button-join {
margin: 0 !important;
width: 60px !important;
height: 30px;
}
.modal-channel-input-wrap {
display: flex;
}
.modal-channel-close {
background: #fff;
width: 40px !important;
margin: 0 !important;
}
.modal-channel-close:hover {
background: #fff;
}
.modal-channel-result {
margin: 15px;
text-align: left;
}
.modal-channel-result-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.modal-channel-topic {
font-size: 12px;
font-family: Roboto Mono, monospace;
color: #444;
}
.modal-channel-name {
cursor: pointer;
margin-right: 15px;
}
.modal-channel-users {
font-size: 16px;
flex: 1;
margin-left: 5px;
}
.modal-channel-results {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.modal-channel-button-more {
margin-bottom: 15px !important;
}
@media (max-width: 600px) {
.tablist {
width: 200px;
@ -827,7 +1123,7 @@ input.message-input-nick.invalid {
}
.navicon {
display: inline-block;
display: block;
}
.main-container.off-canvas {
@ -842,8 +1138,12 @@ input.message-input-nick.invalid {
margin-left: 0;
}
.chat-topic {
font-size: 12px;
.chat-title-bar .editable-wrap {
flex: 1;
}
.chat-topic-wrap {
display: none;
}
.userlist-bar {
@ -884,4 +1184,15 @@ input.message-input-nick.invalid {
.button-install {
margin-left: 50px;
}
.modal-channel {
margin: 0;
width: auto;
height: auto;
position: fixed;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
}
}

View File

@ -1,6 +1,4 @@
var path = require('path');
var exec = require('child_process').exec;
var url = require('url');
var gulp = require('gulp');
var gutil = require('gulp-util');
@ -12,7 +10,7 @@ var br = require('brotli');
var del = require('del');
function brotli(opts) {
return through.obj(function(file, enc, callback) {
return through.obj(function (file, enc, callback) {
if (file.isNull()) {
return callback(null, file);
}
@ -40,8 +38,10 @@ function js(cb) {
process.env['NODE_ENV'] = 'production';
compiler.run(function(err, stats) {
if (err) throw new gutil.PluginError('webpack', err);
compiler.run(function (err, stats) {
if (err) {
throw new gutil.PluginError('webpack', err);
}
gutil.log(
'[webpack]',
@ -50,7 +50,9 @@ function js(cb) {
})
);
if (stats.hasErrors()) process.exit(1);
if (stats.hasErrors()) {
process.exit(1);
}
cb();
});
@ -104,13 +106,13 @@ function serve() {
app.use(
'*',
proxy('localhost:1337', {
proxyReqPathResolver: function(req) {
proxyReqPathResolver: function (req) {
return req.originalUrl;
}
})
);
app.listen(3000, function(err) {
app.listen(3000, function (err) {
if (err) {
console.log(err);
return;
@ -120,9 +122,13 @@ function serve() {
});
}
const assets = gulp.parallel(js, config, public);
const build = gulp.series(clean, assets, compress, cleanup, bindata);
const build = gulp.series(
clean,
gulp.parallel(js, config),
compress,
cleanup,
bindata
);
const dev = gulp.series(
clean,

View File

@ -1,9 +1,10 @@
import { COMMAND } from 'state/actions';
import { join, part, invite, kick, setTopic } from 'state/channels';
import { sendMessage, raw } from 'state/messages';
import { setNick, disconnect, whois, away } from 'state/servers';
import { setNick, disconnect, whois, away } from 'state/networks';
import { openPrivateChat } from 'state/privateChats';
import { select } from 'state/tab';
import { find } from 'utils';
import { find, isChannel } from 'utils';
import createCommandMiddleware, {
beforeHandler,
notFoundHandler
@ -13,7 +14,7 @@ const help = [
'/join <channel> - Join a channel',
'/part [channel] - Leave the current or specified channel',
'/nick <nick> - Change nick',
'/quit - Disconnect from the current server',
'/quit - Disconnect from the current network',
'/me <message> - Send action message',
'/topic [topic] - Show or set topic in the current channel',
'/msg <target> <message> - Send message to the specified channel or user',
@ -22,7 +23,7 @@ const help = [
'/kick <nick> - Kick user from the current channel',
'/whois <nick> - Get information about user',
'/away [message] - Set or clear away message',
'/raw [message] - Send raw IRC message to the current server',
'/raw [message] - Send raw IRC message to the current network',
'/help [command]... - Print help for all or the specified command(s)'
];
@ -33,54 +34,55 @@ const findHelp = cmd =>
find(help, line => line.slice(1, line.indexOf(' ')) === cmd);
export default createCommandMiddleware(COMMAND, {
join({ dispatch, server }, channel) {
join({ dispatch, network }, channel) {
if (channel) {
if (channel[0] !== '#') {
return error('Bad channel name');
}
dispatch(join([channel], server));
dispatch(select(server, channel));
dispatch(join([channel], network));
} else {
return error('Missing channel');
}
},
part({ dispatch, server, channel, isChannel }, partChannel) {
part({ dispatch, network, channel, inChannel }, partChannel) {
if (partChannel) {
dispatch(part([partChannel], server));
} else if (isChannel) {
dispatch(part([channel], server));
dispatch(part([partChannel], network));
} else if (inChannel) {
dispatch(part([channel], network));
} else {
return error('This is not a channel');
}
},
nick({ dispatch, server }, nick) {
nick({ dispatch, network }, nick) {
if (nick) {
dispatch(setNick(nick, server));
dispatch(setNick(nick, network));
} else {
return error('Missing nick');
}
},
quit({ dispatch, server }) {
dispatch(disconnect(server));
quit({ dispatch, network }) {
dispatch(disconnect(network));
},
me({ dispatch, server, channel }, ...message) {
me({ dispatch, network, channel }, ...message) {
const msg = message.join(' ');
if (msg !== '') {
dispatch(sendMessage(`\x01ACTION ${msg}\x01`, channel, server));
dispatch(sendMessage(`\x01ACTION ${msg}\x01`, channel, network));
} else {
return error('Messages can not be empty');
}
},
topic({ dispatch, getState, server, channel }, ...newTopic) {
topic({ dispatch, getState, network, channel }, ...newTopic) {
if (newTopic.length > 0) {
dispatch(setTopic(newTopic.join(' '), channel, server));
} else if (channel) {
const { topic } = getState().channels[server][channel];
dispatch(setTopic(newTopic.join(' '), channel, network));
return;
}
if (channel) {
const { topic } = getState().channels[network][channel];
if (topic) {
return text(topic);
}
@ -88,80 +90,83 @@ export default createCommandMiddleware(COMMAND, {
return 'No topic set';
},
msg({ dispatch, server }, target, ...message) {
msg({ dispatch, network }, target, ...message) {
if (!target) {
return error('Missing nick/channel');
}
const msg = message.join(' ');
if (msg !== '') {
dispatch(sendMessage(message.join(' '), target, server));
dispatch(select(server, target));
dispatch(sendMessage(message.join(' '), target, network));
if (!isChannel(target)) {
dispatch(openPrivateChat(network, target));
}
dispatch(select(network, target));
} else {
return error('Messages can not be empty');
}
},
say({ dispatch, server, channel }, ...message) {
say({ dispatch, network, channel }, ...message) {
if (!channel) {
return error('Messages can only be sent to channels or users');
}
const msg = message.join(' ');
if (msg !== '') {
dispatch(sendMessage(message.join(' '), channel, server));
dispatch(sendMessage(message.join(' '), channel, network));
} else {
return error('Messages can not be empty');
}
},
invite({ dispatch, server, channel, isChannel }, user, inviteChannel) {
if (!inviteChannel && !isChannel) {
invite({ dispatch, network, channel, inChannel }, user, inviteChannel) {
if (!inviteChannel && !inChannel) {
return error('This is not a channel');
}
if (user && inviteChannel) {
dispatch(invite(user, inviteChannel, server));
dispatch(invite(user, inviteChannel, network));
} else if (user && channel) {
dispatch(invite(user, channel, server));
dispatch(invite(user, channel, network));
} else {
return error('Missing nick');
}
},
kick({ dispatch, server, channel, isChannel }, user) {
if (!isChannel) {
kick({ dispatch, network, channel, inChannel }, user) {
if (!inChannel) {
return error('This is not a channel');
}
if (user) {
dispatch(kick(user, channel, server));
dispatch(kick(user, channel, network));
} else {
return error('Missing nick');
}
},
whois({ dispatch, server }, user) {
whois({ dispatch, network }, user) {
if (user) {
dispatch(whois(user, server));
dispatch(whois(user, network));
} else {
return error('Missing nick');
}
},
away({ dispatch, server }, ...message) {
away({ dispatch, network }, ...message) {
const msg = message.join(' ');
dispatch(away(msg, server));
dispatch(away(msg, network));
if (msg !== '') {
return 'Away message set';
}
return 'Away message cleared';
},
raw({ dispatch, server }, ...message) {
raw({ dispatch, network }, ...message) {
if (message.length > 0 && message[0] !== '') {
const cmd = `${message[0].toUpperCase()} ${message.slice(1).join(' ')}`;
dispatch(raw(cmd, server));
dispatch(raw(cmd, network));
return prompt(`=> ${cmd}`);
}
return [prompt('=> /raw'), error('Missing message')];
@ -185,9 +190,6 @@ export default createCommandMiddleware(COMMAND, {
},
[notFoundHandler](ctx, command, ...params) {
if (command === command.toUpperCase()) {
return this.raw(ctx, command, ...params);
}
return error(`=> /${command}: No such command`);
return this.raw(ctx, command, ...params);
}
});

View File

@ -1,25 +1,40 @@
import React, { Suspense, lazy } from 'react';
import React, { Suspense, lazy, useState, useEffect } from 'react';
import Route from 'containers/Route';
import AppInfo from 'components/AppInfo';
import TabList from 'components/TabList';
import cn from 'classnames';
const Modals = lazy(() => import('components/modals'));
const Chat = lazy(() => import('containers/Chat'));
const Connect = lazy(() => import('containers/Connect'));
const Connect = lazy(() =>
import(/* webpackChunkName: "connect" */ 'containers/Connect')
);
const Settings = lazy(() => import('containers/Settings'));
const App = ({
connected,
tab,
channels,
servers,
networks,
privateChats,
showTabList,
select,
push,
hideMenu,
newVersionAvailable
openModal,
newVersionAvailable,
hasOpenModals
}) => {
const [renderModals, setRenderModals] = useState(false);
if (!renderModals && hasOpenModals) {
setRenderModals(true);
}
const [starting, setStarting] = useState(true);
useEffect(() => {
setTimeout(() => setStarting(false), 1000);
}, []);
const mainClass = cn('main-container', {
'off-canvas': showTabList
});
@ -32,7 +47,7 @@ const App = ({
return (
<div className="wrap" onClick={handleClick}>
{!connected && (
{!starting && !connected && (
<AppInfo type="error">
Connection lost, attempting to reconnect...
</AppInfo>
@ -47,17 +62,15 @@ const App = ({
<TabList
tab={tab}
channels={channels}
servers={servers}
networks={networks}
privateChats={privateChats}
showTabList={showTabList}
select={select}
push={push}
openModal={openModal}
/>
<div className={mainClass}>
<Suspense
maxDuration={1000}
fallback={<div className="suspense-fallback">...</div>}
>
<Suspense fallback={<div className="suspense-fallback">...</div>}>
<Route name="chat">
<Chat />
</Route>
@ -68,6 +81,11 @@ const App = ({
<Settings />
</Route>
</Suspense>
<Suspense
fallback={<div className="suspense-modal-fallback">...</div>}
>
{renderModals && <Modals />}
</Suspense>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,40 +11,40 @@ export default class Chat extends Component {
const { tab, part, closePrivateChat, disconnect } = this.props;
if (isChannel(tab)) {
part([tab.name], tab.server);
part([tab.name], tab.network);
} else if (tab.name) {
closePrivateChat(tab.server, tab.name);
closePrivateChat(tab.network, tab.name);
} else {
disconnect(tab.server);
disconnect(tab.network);
}
};
handleSearch = phrase => {
const { tab, searchMessages } = this.props;
if (isChannel(tab)) {
searchMessages(tab.server, tab.name, phrase);
searchMessages(tab.network, tab.name, phrase);
}
};
handleNickClick = nick => {
const { tab, openPrivateChat, select } = this.props;
openPrivateChat(tab.server, nick);
select(tab.server, nick);
openPrivateChat(tab.network, nick);
select(tab.network, nick);
};
handleTitleChange = title => {
const { setServerName, tab } = this.props;
setServerName(title, tab.server);
const { setNetworkName, tab } = this.props;
setNetworkName(title, tab.network);
};
handleNickChange = nick => {
const { setNick, tab } = this.props;
setNick(nick, tab.server, true);
setNick(nick, tab.network, true);
};
handleNickEditDone = nick => {
const { setNick, tab } = this.props;
setNick(nick, tab.server);
setNick(nick, tab.network);
};
render() {
@ -57,7 +57,7 @@ export default class Chat extends Component {
nick,
search,
showUserList,
status,
error,
tab,
title,
users,
@ -65,6 +65,7 @@ export default class Chat extends Component {
addFetchedMessages,
fetchMessages,
inputActions,
openModal,
runCommand,
sendMessage,
toggleSearch,
@ -76,16 +77,17 @@ export default class Chat extends Component {
} else if (tab.name) {
chatClass = 'chat-private';
} else {
chatClass = 'chat-server';
chatClass = 'chat-network';
}
return (
<div className={chatClass}>
<ChatTitle
channel={channel}
status={status}
error={error}
tab={tab}
title={title}
openModal={openModal}
onCloseClick={this.handleCloseClick}
onTitleChange={this.handleTitleChange}
onToggleSearch={toggleSearch}
@ -97,6 +99,7 @@ export default class Chat extends Component {
hasMoreMessages={hasMoreMessages}
messages={messages}
tab={tab}
hideTopDate={search.show}
onAddMore={addFetchedMessages}
onFetchMore={fetchMessages}
onNickClick={this.handleNickClick}

View File

@ -1,14 +1,17 @@
import React, { memo } from 'react';
import Navicon from 'containers/Navicon';
import { FiUsers, FiSearch, FiX } from 'react-icons/fi';
import Navicon from 'components/ui/Navicon';
import Button from 'components/ui/Button';
import Editable from 'components/ui/Editable';
import { isValidServerName } from 'state/servers';
import { isChannel, linkify } from 'utils';
import { isValidNetworkName } from 'state/networks';
import { isChannel } from 'utils';
const ChatTitle = ({
status,
error,
title,
tab,
channel,
openModal,
onTitleChange,
onToggleSearch,
onToggleUserList,
@ -23,14 +26,9 @@ const ChatTitle = ({
closeTitle = 'Disconnect';
}
let serverError = null;
if (!tab.name && status.error) {
serverError = (
<span className="chat-topic error">
Error:
{status.error}
</span>
);
let networkError = null;
if (!tab.name && error) {
networkError = <span className="chat-topic error">Error: {error}</span>;
}
return (
@ -41,30 +39,46 @@ const ChatTitle = ({
className="chat-title"
editable={!tab.name}
value={title}
validate={isValidServerName}
validate={isValidNetworkName}
onChange={onTitleChange}
>
<span className="chat-title">{title}</span>
</Editable>
<div className="chat-topic-wrap">
<span className="chat-topic">
{channel && linkify(channel.topic)}
</span>
{serverError}
{channel?.topic && (
<span
className="chat-topic"
onClick={() => openModal('topic', channel.name)}
>
{channel.topic}
</span>
)}
{networkError}
</div>
<i className="icon-search" title="Search" onClick={onToggleSearch} />
<i
className="icon-cancel button-leave"
{tab.name && (
<Button
icon={FiSearch}
title="Search"
aria-label="Search"
onClick={onToggleSearch}
/>
)}
<Button
icon={FiX}
title={closeTitle}
aria-label={closeTitle}
onClick={onCloseClick}
/>
<i className="icon-user button-userlist" onClick={onToggleUserList} />
<Button
icon={FiUsers}
className="button-userlist"
aria-label="Users"
onClick={onToggleUserList}
/>
</div>
<div className="userlist-bar">
<i className="icon-user" />
<span className="chat-usercount">
{channel && channel.users.length}
</span>
<FiUsers />
{channel?.users.length}
</div>
</div>
);

View File

@ -1,16 +1,25 @@
import React, { memo } from 'react';
import classnames from 'classnames';
import Text from 'components/Text';
import stringToRGB from 'utils/color';
const Message = ({ message, coloredNick, style, onNickClick }) => {
const Message = ({ message, coloredNick, onNickClick }) => {
const className = classnames('message', {
[`message-${message.type}`]: message.type
});
style = {
...style,
paddingLeft: `${window.messageIndent + 15}px`,
textIndent: `-${window.messageIndent}px`
if (message.type === 'date') {
return (
<div className={className}>
{message.content}
<hr />
</div>
);
}
const style = {
paddingLeft: `${message.indent + 15}px`,
textIndent: `-${message.indent}px`
};
const senderStyle = {};
@ -30,7 +39,10 @@ const Message = ({ message, coloredNick, style, onNickClick }) => {
{message.from}
</span>
)}
<span> {message.content}</span>
<span>
{' '}
<Text coloredNick={coloredNick}>{message.content}</Text>
</span>
</p>
);
};

View File

@ -2,23 +2,41 @@ import React, { PureComponent, createRef } from 'react';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import debounce from 'lodash/debounce';
import { formatDate, measureScrollBarWidth } from 'utils';
import { getScrollPos, saveScrollPos } from 'utils/scrollPosition';
import { windowHeight } from 'utils/size';
import Message from './Message';
const fetchThreshold = 600;
// The amount of time in ms that needs to pass without any
// scroll events happening before adding messages to the top,
// this is done to prevent the scroll from jumping all over the place
const scrollbackDebounce = 100;
const scrollbackDebounce = 150;
const scrollBarWidth = `${measureScrollBarWidth()}px`;
const hasSameLastMessage = (m1, m2) => {
if (m1.length === 0 || m2.length === 0) {
if (m1.length === 0 && m2.length === 0) {
return true;
}
return false;
}
return m1[m1.length - 1].id === m2[m2.length - 1].id;
};
export default class MessageBox extends PureComponent {
state = { topDate: '' };
list = createRef();
outer = createRef();
addMore = debounce(() => {
const { tab, onAddMore } = this.props;
this.ready = true;
onAddMore(tab.server, tab.name);
onAddMore(tab.network, tab.name);
}, scrollbackDebounce);
constructor(props) {
@ -27,19 +45,9 @@ export default class MessageBox extends PureComponent {
this.loadScrollPos();
}
componentDidMount() {
const scrollToBottom = this.bottom;
window.requestAnimationFrame(() => {
const { messages } = this.props;
if (scrollToBottom && messages.length > 0) {
this.list.current.scrollToItem(messages.length + 1);
}
});
}
componentDidUpdate(prevProps) {
const { messages } = this.props;
if (prevProps.tab !== this.props.tab) {
this.loadScrollPos(true);
}
@ -47,8 +55,11 @@ export default class MessageBox extends PureComponent {
if (this.nextScrollTop > 0) {
this.list.current.scrollTo(this.nextScrollTop);
this.nextScrollTop = 0;
} else if (this.bottom) {
this.list.current.scrollToItem(this.props.messages.length + 1);
} else if (
this.bottom &&
!hasSameLastMessage(messages, prevProps.messages)
) {
this.list.current.scrollToItem(messages.length + 1);
}
}
@ -69,7 +80,7 @@ export default class MessageBox extends PureComponent {
if (prevProps.messages[0] !== this.props.messages[0]) {
const { messages, hasMoreMessages } = this.props;
if (prevProps.tab === this.props.tab) {
if (prevProps.tab === this.props.tab && prevProps.messages.length > 0) {
const addedMessages = messages.length - prevProps.messages.length;
let addedHeight = 0;
for (let i = 0; i < addedMessages; i++) {
@ -98,7 +109,8 @@ export default class MessageBox extends PureComponent {
return 100;
}
return 7;
} else if (index === messages.length + 1) {
}
if (index === messages.length + 1) {
return 7;
}
return messages[index - 1].height;
@ -109,7 +121,8 @@ export default class MessageBox extends PureComponent {
if (index === 0) {
return 'top';
} else if (index === messages.length + 1) {
}
if (index === messages.length + 1) {
return 'bottom';
}
return messages[index - 1].id;
@ -117,12 +130,14 @@ export default class MessageBox extends PureComponent {
updateScrollKey = () => {
const { tab } = this.props;
this.scrollKey = `msg:${tab.server}:${tab.name}`;
this.scrollKey = `msg:${tab.network}:${tab.name}`;
return this.scrollKey;
};
loadScrollPos = scroll => {
const pos = getScrollPos(this.updateScrollKey());
const { messages } = this.props;
if (pos >= 0) {
this.bottom = false;
if (scroll) {
@ -133,7 +148,17 @@ export default class MessageBox extends PureComponent {
} else {
this.bottom = true;
if (scroll) {
this.list.current.scrollToItem(this.props.messages.length + 1);
this.list.current.scrollToItem(messages.length + 1);
} else if (messages.length > 0) {
let totalHeight = 14;
for (let i = 0; i < messages.length; i++) {
totalHeight += messages[i].height;
}
const messageBoxHeight = windowHeight() - 100;
if (totalHeight > messageBoxHeight) {
this.initialScrollTop = totalHeight;
}
}
}
};
@ -142,7 +167,7 @@ export default class MessageBox extends PureComponent {
if (this.bottom) {
saveScrollPos(this.scrollKey, -1);
} else {
saveScrollPos(this.scrollKey, this.outer.current.scrollTop);
saveScrollPos(this.scrollKey, this.scrollTop);
}
};
@ -172,9 +197,21 @@ export default class MessageBox extends PureComponent {
const { clientHeight, scrollHeight } = this.outer.current;
this.scrollTop = scrollOffset;
this.bottom = scrollOffset + clientHeight >= scrollHeight - 20;
};
handleItemsRendered = ({ visibleStartIndex }) => {
const startIndex = visibleStartIndex === 0 ? 0 : visibleStartIndex - 1;
const firstVisibleMessage = this.props.messages[startIndex];
if (firstVisibleMessage && firstVisibleMessage.date) {
this.setState({ topDate: formatDate(firstVisibleMessage.date) });
} else {
this.setState({ topDate: '' });
}
};
handleMouseDown = () => {
this.mouseDown = true;
};
@ -185,7 +222,7 @@ export default class MessageBox extends PureComponent {
if (this.shouldAdd) {
const { tab, onAddMore } = this.props;
this.shouldAdd = false;
onAddMore(tab.server, tab.name);
onAddMore(tab.network, tab.name);
}
};
@ -201,7 +238,8 @@ export default class MessageBox extends PureComponent {
);
}
return null;
} else if (index === messages.length + 1) {
}
if (index === messages.length + 1) {
return null;
}
@ -209,22 +247,38 @@ export default class MessageBox extends PureComponent {
const message = messages[index - 1];
return (
<Message
message={message}
coloredNick={coloredNicks}
style={style}
onNickClick={onNickClick}
/>
<div style={style}>
<Message
message={message}
coloredNick={coloredNicks}
onNickClick={onNickClick}
/>
</div>
);
};
render() {
const { messages, hideTopDate } = this.props;
const { topDate } = this.state;
const dateContainerStyle = {
right: scrollBarWidth
};
return (
<div
className="messagebox"
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
>
<div
className="messagebox-topdate-container"
style={dateContainerStyle}
>
{!hideTopDate && topDate && (
<span className="messagebox-topdate">{topDate}</span>
)}
</div>
<AutoSizer>
{({ width, height }) => (
<List
@ -232,12 +286,13 @@ export default class MessageBox extends PureComponent {
outerRef={this.outer}
width={width}
height={height}
itemCount={this.props.messages.length + 2}
itemCount={messages.length + 2}
itemKey={this.getItemKey}
itemSize={this.getRowHeight}
estimatedItemSize={32}
initialScrollOffset={this.initialScrollTop}
onScroll={this.handleScroll}
onItemsRendered={this.handleItemsRendered}
className="messagebox-window"
overscanCount={5}
>

View File

@ -21,9 +21,9 @@ const MessageInput = ({
const handleKey = e => {
if (e.key === 'Enter' && e.target.value) {
if (e.target.value[0] === '/') {
onCommand(e.target.value, tab.name, tab.server);
onCommand(e.target.value, tab.name, tab.network);
} else if (tab.name) {
onMessage(e.target.value, tab.name, tab.server);
onMessage(e.target.value, tab.name, tab.network);
}
add(e.target.value);

View File

@ -1,17 +1,15 @@
import React, { memo, useRef, useEffect } from 'react';
import { FiSearch } from 'react-icons/fi';
import SearchResult from './SearchResult';
const Search = ({ search, onSearch }) => {
const inputEl = useRef();
useEffect(
() => {
if (search.show) {
inputEl.current.focus();
}
},
[search.show]
);
useEffect(() => {
if (search.show) {
inputEl.current.focus();
}
}, [search.show]);
const style = {
display: search.show ? 'block' : 'none'
@ -25,7 +23,7 @@ const Search = ({ search, onSearch }) => {
return (
<div className="search" style={style}>
<div className="search-input-wrap">
<i className="icon-search" />
<FiSearch className="search-input-icon" />
<input
ref={inputEl}
className="search-input"

View File

@ -1,4 +1,5 @@
import React, { memo } from 'react';
import Text from 'components/Text';
import { timestamp, linkify } from 'utils';
const SearchResult = ({ result }) => {
@ -16,7 +17,10 @@ const SearchResult = ({ result }) => {
{' '}
<span className="message-sender">{result.from}</span>
</span>
<span> {linkify(result.content)}</span>
<span>
{' '}
<Text>{linkify(result.content)}</Text>
</span>
</p>
);
};

View File

@ -28,7 +28,8 @@ export default class UserList extends PureComponent {
if (index === 0) {
return 12;
} else if (index === users.length + 1) {
}
if (index === users.length + 1) {
return 10;
}
return 24;
@ -39,7 +40,8 @@ export default class UserList extends PureComponent {
if (index === 0) {
return 'top';
} else if (index === users.length + 1) {
}
if (index === users.length + 1) {
return 'bottom';
}
return index;

View File

@ -1,7 +1,8 @@
import React, { Component } from 'react';
import { createSelector } from 'reselect';
import { Form, withFormik } from 'formik';
import Navicon from 'containers/Navicon';
import { FiMoreHorizontal } from 'react-icons/fi';
import Navicon from 'components/ui/Navicon';
import Button from 'components/ui/Button';
import Checkbox from 'components/ui/formik/Checkbox';
import TextInput from 'components/ui/TextInput';
@ -13,6 +14,26 @@ const getSortedDefaultChannels = createSelector(
channels => channels.split(',').sort()
);
const transformChannels = channels => {
const comma = channels[channels.length - 1] === ',';
channels = channels
.split(',')
.map(channel => {
channel = channel.trim();
if (channel) {
if (isValidChannel(channel, false) && channel[0] !== '#') {
channel = `#${channel}`;
}
}
return channel;
})
.filter(s => s)
.join(',');
return comma ? `${channels},` : channels;
};
class Connect extends Component {
state = {
showOptionals: false
@ -20,10 +41,10 @@ class Connect extends Component {
handleSSLChange = e => {
const { values, setFieldValue } = this.props;
if (e.target.checked && values.port === 6667) {
setFieldValue('port', 6697, false);
} else if (!e.target.checked && values.port === 6697) {
setFieldValue('port', 6667, false);
if (e.target.checked && values.port === '6667') {
setFieldValue('port', '6697', false);
} else if (!e.target.checked && values.port === '6697') {
setFieldValue('port', '6667', false);
}
};
@ -35,14 +56,31 @@ class Connect extends Component {
const { hexIP } = this.props;
return (
<div>
<>
<div className="connect-section">
<h2>SASL</h2>
<TextInput name="account" />
<TextInput name="password" type="password" noTrim />
</div>
{!hexIP && <TextInput name="username" />}
<TextInput name="password" type="password" />
<TextInput name="realname" />
</div>
<TextInput
name="serverPassword"
label="Server Password"
type="password"
noTrim
/>
<TextInput name="realname" noTrim />
</>
);
};
transformPort = port => {
if (!port) {
return this.props.values.tls ? '6697' : '6667';
}
return port;
};
render() {
const { defaults, values } = this.props;
const { readOnly, showDetails } = defaults;
@ -70,11 +108,17 @@ class Connect extends Component {
form = (
<Form className="connect-form">
<h1>Connect</h1>
<TextInput name="name" autoCapitalize="words" />
<TextInput name="name" autoCapitalize="words" noTrim />
<div className="connect-form-address">
<TextInput name="host" noError />
<TextInput name="port" type="number" noError />
<TextInput
name="port"
type="number"
blurTransform={this.transformPort}
noError
/>
<Checkbox
classNameLabel="connect-form-ssl"
name="tls"
label="SSL"
topLabel
@ -84,9 +128,14 @@ class Connect extends Component {
<Error name="host" />
<Error name="port" />
<TextInput name="nick" />
<TextInput name="channels" />
<TextInput name="channels" transform={transformChannels} />
{this.state.showOptionals && this.renderOptionals()}
<i className="icon-ellipsis" onClick={this.handleShowClick} />
<Button
className="connect-form-button-optionals"
icon={FiMoreHorizontal}
aria-label="Show more"
onClick={this.handleShowClick}
/>
<Button type="submit">Connect</Button>
</Form>
);
@ -103,33 +152,43 @@ class Connect extends Component {
export default withFormik({
enableReinitialize: true,
mapPropsToValues: ({ defaults }) => {
let port = 6667;
if (defaults.port) {
({ port } = defaults);
mapPropsToValues: ({ defaults, query }) => {
let port = '6667';
if (query.port || defaults.port) {
port = query.port || defaults.port;
} else if (defaults.ssl) {
port = 6697;
port = '6697';
}
let { channels } = query;
if (channels) {
channels = transformChannels(channels);
}
let ssl;
if (query.ssl === 'true') {
ssl = true;
} else if (query.ssl === 'false') {
ssl = false;
} else {
ssl = defaults.ssl || false;
}
return {
name: defaults.name,
host: defaults.host,
name: query.name || defaults.name,
host: query.host || defaults.host,
port,
nick: '',
channels: defaults.channels.join(','),
username: '',
password: defaults.password ? ' ' : '',
realname: '',
tls: defaults.ssl
nick: query.nick || localStorage.lastNick || '',
channels: channels || defaults.channels.join(','),
account: '',
password: '',
username: query.username || '',
serverPassword: defaults.serverPassword ? ' ' : '',
realname: query.realname || localStorage.lastRealname || '',
tls: ssl
};
},
validate: values => {
Object.keys(values).forEach(k => {
if (typeof values[k] === 'string') {
values[k] = values[k].trim();
}
});
const errors = {};
if (!values.host) {
@ -138,9 +197,7 @@ export default withFormik({
errors.host = 'Invalid host';
}
if (!values.port) {
values.port = values.tls ? 6697 : 6667;
} else if (!isInt(values.port, 1, 65535)) {
if (!isInt(values.port, 1, 65535)) {
errors.port = 'Invalid port';
}
@ -154,29 +211,24 @@ export default withFormik({
errors.username = 'Invalid username';
}
values.channels = values.channels
.split(',')
.map(channel => {
channel = channel.trim();
if (channel) {
if (isValidChannel(channel, false)) {
if (channel[0] !== '#') {
channel = `#${channel}`;
}
} else {
errors.channels = 'Invalid channel(s)';
}
}
return channel;
})
.filter(s => s)
.join(',');
const channels = values.channels.split(',');
for (let i = channels.length - 1; i >= 0; i--) {
if (i === channels.length - 1 && channels[i] === '') {
/* eslint-disable-next-line no-continue */
continue;
}
if (!isValidChannel(channels[i])) {
errors.channels = 'Invalid channel(s)';
break;
}
}
return errors;
},
handleSubmit: (values, { props }) => {
const { connect, select, join } = props;
const channels = values.channels.split(',');
const channels = values.channels ? values.channels.split(',') : [];
delete values.channels;
values.port = `${values.port}`;
@ -184,7 +236,12 @@ export default withFormik({
select(values.host);
if (channels.length > 0) {
join(channels, values.host);
join(channels, values.host, false);
}
localStorage.lastNick = values.nick;
if (values.realname) {
localStorage.lastRealname = values.realname;
}
}
})(Connect);

View File

@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import Navicon from 'containers/Navicon';
import Navicon from 'components/ui/Navicon';
import Button from 'components/ui/Button';
import Checkbox from 'components/ui/Checkbox';
import FileInput from 'components/ui/FileInput';
@ -7,6 +7,7 @@ import FileInput from 'components/ui/FileInput';
const Settings = ({
settings,
installable,
version,
setSetting,
onCertChange,
onKeyChange,
@ -16,14 +17,11 @@ const Settings = ({
const status = settings.uploadingCert ? 'Uploading...' : 'Upload';
const error = settings.certError;
const handleInstallClick = useCallback(
async () => {
installable.prompt();
await installable.userChoice;
onInstall();
},
[installable]
);
const handleInstallClick = useCallback(async () => {
installable.prompt();
await installable.userChoice;
onInstall();
}, [installable]);
return (
<div className="settings-container">
@ -31,7 +29,10 @@ const Settings = ({
<Navicon />
<h1>Settings</h1>
{installable && (
<Button className="button-install" onClick={handleInstallClick}>
<Button
className="settings-button button-install"
onClick={handleInstallClick}
>
<h2>Install</h2>
</Button>
)}
@ -40,7 +41,7 @@ const Settings = ({
<Checkbox
name="coloredNicks"
label="Colored nicks"
checked={settings.coloredNicks}
checked={!!settings.coloredNicks}
onChange={e => setSetting('coloredNicks', e.target.checked)}
/>
</div>
@ -71,6 +72,13 @@ const Settings = ({
{error ? <p className="error">{error}</p> : null}
</div>
</div>
{version && (
<div className="settings-version">
<p>{version.tag}</p>
<p>Commit: {version.commit}</p>
<p>Build Date: {version.date}</p>
</div>
)}
</div>
</div>
);

View File

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

View File

@ -1,9 +1,9 @@
import React from 'react';
import classnames from 'classnames';
const Checkbox = ({ name, label, topLabel, ...props }) => (
const Checkbox = ({ name, label, topLabel, classNameLabel, ...props }) => (
<label
className={classnames('checkbox', {
className={classnames('checkbox', classNameLabel, {
'top-label': topLabel
})}
htmlFor={name}

View File

@ -1,4 +1,5 @@
import React, { PureComponent, createRef } from 'react';
import cn from 'classnames';
import { stringWidth } from 'utils';
export default class Editable extends PureComponent {
@ -17,11 +18,7 @@ export default class Editable extends PureComponent {
// eslint-disable-next-line react/no-did-update-set-state
this.updateInputWidth(this.props.value);
this.inputEl.current.focus();
}
}
getSnapshotBeforeUpdate(prevProps) {
if (this.state.editing && prevProps.value !== this.props.value) {
} else if (this.state.editing && prevProps.value !== this.props.value) {
this.updateInputWidth(this.props.value);
}
}
@ -79,7 +76,7 @@ export default class Editable extends PureComponent {
};
render() {
const { children, className, value } = this.props;
const { children, className, editable, value } = this.props;
const style = {
width: this.state.width,
@ -90,7 +87,7 @@ export default class Editable extends PureComponent {
return this.state.editing ? (
<input
ref={this.inputEl}
className={className}
className={`editable-wrap ${className}`}
type="text"
value={value}
onBlur={this.handleBlur}
@ -101,7 +98,14 @@ export default class Editable extends PureComponent {
spellCheck={false}
/>
) : (
<div onClick={this.startEditing}>{children}</div>
<div
className={cn('editable-wrap', {
'editable-wrap-editable': editable
})}
onClick={this.startEditing}
>
{children}
</div>
);
}
}

View File

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

View File

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

View File

@ -5,22 +5,19 @@ import Checkbox from 'components/ui/Checkbox';
const FormikCheckbox = ({ name, onChange, ...props }) => (
<FastField
name={name}
render={({ field, form }) => {
return (
<Checkbox
name={name}
checked={field.value}
onChange={e => {
form.setFieldTouched(name, true);
field.onChange(e);
if (onChange) {
onChange(e);
}
}}
{...props}
/>
);
}}
render={({ field }) => (
<Checkbox
name={name}
checked={field.value}
onChange={e => {
field.onChange(e);
if (onChange) {
onChange(e);
}
}}
{...props}
/>
)}
/>
);

View File

@ -2,8 +2,9 @@ import { createStructuredSelector } from 'reselect';
import App from 'components/App';
import { getConnected } from 'state/app';
import { getSortedChannels } from 'state/channels';
import { openModal, getHasOpenModals } from 'state/modals';
import { getPrivateChats } from 'state/privateChats';
import { getServers } from 'state/servers';
import { getNetworks } from 'state/networks';
import { getSelectedTab, select } from 'state/tab';
import { getShowTabList, hideMenu } from 'state/ui';
import connect from 'utils/connect';
@ -13,15 +14,13 @@ const mapState = createStructuredSelector({
channels: getSortedChannels,
connected: getConnected,
privateChats: getPrivateChats,
servers: getServers,
networks: getNetworks,
showTabList: getShowTabList,
tab: getSelectedTab,
newVersionAvailable: state => state.app.newVersionAvailable
newVersionAvailable: state => state.app.newVersionAvailable,
hasOpenModals: getHasOpenModals
});
const mapDispatch = { push, select, hideMenu };
const mapDispatch = { push, select, hideMenu, openModal };
export default connect(
mapState,
mapDispatch
)(App);
export default connect(mapState, mapDispatch)(App);

View File

@ -22,15 +22,16 @@ import {
fetchMessages,
addFetchedMessages
} from 'state/messages';
import { openModal } from 'state/modals';
import { openPrivateChat, closePrivateChat } from 'state/privateChats';
import { getSearch, searchMessages, toggleSearch } from 'state/search';
import {
getCurrentNick,
getCurrentServerStatus,
getCurrentNetworkError,
disconnect,
setNick,
setServerName
} from 'state/servers';
setNetworkName
} from 'state/networks';
import { getSettings } from 'state/settings';
import { getSelectedTab, select } from 'state/tab';
import { getShowUserList, toggleUserList } from 'state/ui';
@ -44,7 +45,7 @@ const mapState = createStructuredSelector({
nick: getCurrentNick,
search: getSearch,
showUserList: getShowUserList,
status: getCurrentServerStatus,
error: getCurrentNetworkError,
tab: getSelectedTab,
title: getSelectedTabTitle,
users: getSelectedChannelUsers,
@ -58,6 +59,7 @@ const mapDispatch = dispatch => ({
closePrivateChat,
disconnect,
fetchMessages,
openModal,
openPrivateChat,
part,
runCommand,
@ -65,7 +67,7 @@ const mapDispatch = dispatch => ({
select,
sendMessage,
setNick,
setServerName,
setNetworkName,
toggleSearch,
toggleUserList
},
@ -83,7 +85,4 @@ const mapDispatch = dispatch => ({
)
});
export default connect(
mapState,
mapDispatch
)(Chat);
export default connect(mapState, mapDispatch)(Chat);

View File

@ -2,22 +2,20 @@ import { createStructuredSelector } from 'reselect';
import Connect from 'components/pages/Connect';
import { getConnectDefaults, getApp } from 'state/app';
import { join } from 'state/channels';
import { connect as connectServer } from 'state/servers';
import { connect as connectNetwork } from 'state/networks';
import { select } from 'state/tab';
import connect from 'utils/connect';
const mapState = createStructuredSelector({
defaults: getConnectDefaults,
hexIP: state => getApp(state).hexIP
hexIP: state => getApp(state).hexIP,
query: state => state.router.query
});
const mapDispatch = {
join,
connect: connectServer,
connect: connectNetwork,
select
};
export default connect(
mapState,
mapDispatch
)(Connect);
export default connect(mapState, mapDispatch)(Connect);

View File

@ -1,12 +0,0 @@
import Navicon from 'components/ui/Navicon';
import { toggleMenu } from 'state/ui';
import connect from 'utils/connect';
const mapDispatch = {
onClick: toggleMenu
};
export default connect(
null,
mapDispatch
)(Navicon);

View File

@ -12,7 +12,8 @@ import connect from 'utils/connect';
const mapState = createStructuredSelector({
settings: getSettings,
installable: state => state.app.installable
installable: state => state.app.installable,
version: state => state.app.version
});
const mapDispatch = {
@ -23,7 +24,4 @@ const mapDispatch = {
onInstall: () => appSet('installable', null)
};
export default connect(
mapState,
mapDispatch
)(Settings);
export default connect(mapState, mapDispatch)(Settings);

View File

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

8
client/js/hot.js Normal file
View File

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

View File

@ -1,5 +1,6 @@
import './hot';
import React from 'react';
import { createRoot } from 'react-dom';
import { render } from 'react-dom';
import Root from 'components/Root';
import { appSet } from 'state/app';
@ -10,7 +11,6 @@ import routes from './routes';
import runModules from './modules';
import { register } from './serviceWorker';
import '../css/fonts.css';
import '../css/fontello.css';
import '../css/style.css';
const production = process.env.NODE_ENV === 'production';
@ -23,7 +23,7 @@ const store = configureStore(socket);
initRouter(routes, store);
runModules({ store, socket });
createRoot(document.getElementById('root')).render(<Root store={store} />);
render(<Root store={store} />, document.getElementById('root'));
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();

View File

@ -4,22 +4,27 @@ import { isChannel } from 'utils';
export const beforeHandler = '_before';
export const notFoundHandler = 'commandNotFound';
function createContext({ dispatch, getState }, { server, channel }) {
return { dispatch, getState, server, channel, isChannel: isChannel(channel) };
function createContext({ dispatch, getState }, { network, channel }) {
return {
dispatch,
getState,
network,
channel,
inChannel: isChannel(channel)
};
}
// TODO: Pull this out as convenience action
function process({ dispatch, server, channel }, result) {
function process({ dispatch, network, channel }, result) {
if (typeof result === 'string') {
dispatch(inform(result, server, channel));
dispatch(inform(result, network, channel));
} else if (Array.isArray(result)) {
if (typeof result[0] === 'string') {
dispatch(inform(result, server, channel));
dispatch(inform(result, network, channel));
} else if (typeof result[0] === 'object') {
dispatch(addMessages(result, server, channel));
dispatch(addMessages(result, network, channel));
}
} else if (typeof result === 'object' && result) {
dispatch(print(result.content, server, channel, result.type));
dispatch(print(result.content, network, channel, result.type));
}
}
@ -27,7 +32,7 @@ export default function createCommandMiddleware(type, handlers) {
return store => next => action => {
if (action.type === type) {
const words = action.command.slice(1).split(' ');
const command = words[0];
const command = words[0].toLowerCase();
const params = words.slice(1);
if (command in handlers) {

View File

@ -10,7 +10,7 @@ const message = store => next => {
return action => {
if (action.type === ADD_MESSAGES && action.prepend) {
const key = `${action.server} ${action.channel}`;
const key = `${action.network} ${action.channel}`;
if (ready[key]) {
ready[key] = false;
@ -19,7 +19,7 @@ const message = store => next => {
cache[key] = action;
} else if (action.type === ADD_FETCHED_MESSAGES) {
const key = `${action.server} ${action.channel}`;
const key = `${action.network} ${action.channel}`;
ready[key] = true;
if (cache[key]) {

View File

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

View File

@ -1,22 +1,15 @@
import FontFaceObserver from 'fontfaceobserver';
import { setCharWidth } from 'state/app';
import { stringWidth } from 'utils';
export default function fonts({ store }) {
export default async function fonts({ store }) {
let { charWidth } = localStorage;
if (charWidth) {
store.dispatch(setCharWidth(parseFloat(charWidth)));
} else {
await document.fonts.load('16px Roboto Mono');
charWidth = stringWidth(' ', '16px Roboto Mono');
store.dispatch(setCharWidth(charWidth));
localStorage.charWidth = charWidth;
}
new FontFaceObserver('Roboto Mono').load().then(() => {
if (!charWidth) {
charWidth = stringWidth(' ', '16px Roboto Mono');
store.dispatch(setCharWidth(charWidth));
localStorage.charWidth = charWidth;
}
});
new FontFaceObserver('Montserrat').load();
new FontFaceObserver('Montserrat', { weight: 700 }).load();
new FontFaceObserver('Roboto Mono', { weight: 700 }).load();
}

View File

@ -1,6 +1,7 @@
import documentTitle from './documentTitle';
import fonts from './fonts';
import initialState from './initialState';
import route from './route';
import socket from './socket';
import storage from './storage';
import widthUpdates from './widthUpdates';
@ -8,6 +9,7 @@ import widthUpdates from './widthUpdates';
export default function runModules(ctx) {
fonts(ctx);
initialState(ctx);
route(ctx);
documentTitle(ctx);
socket(ctx);

View File

@ -1,75 +1,45 @@
/* eslint-disable no-underscore-dangle */
import Cookie from 'js-cookie';
import { socket as socketActions } from 'state/actions';
import { getWrapWidth, setConnectDefaults, appSet } from 'state/app';
import { INIT } from 'state/actions';
import { getConnected, getWrapWidth } from 'state/app';
import { searchChannels } from 'state/channelSearch';
import { addMessages } from 'state/messages';
import { setSettings } from 'state/settings';
import { select, updateSelection } from 'state/tab';
import { find } from 'utils';
import { when } from 'utils/observe';
import { replace } from 'utils/router';
function loadState({ store }, env) {
store.dispatch(setConnectDefaults(env.defaults));
store.dispatch(appSet('hexIP', env.hexIP));
store.dispatch(setSettings(env.settings, true));
if (env.servers) {
store.dispatch({
type: socketActions.SERVERS,
data: env.servers
});
if (!store.getState().router.route) {
const tab = Cookie.get('tab');
if (tab) {
const [server, name = null] = tab.split(/;(.+)/);
if (
name &&
find(
env.channels,
chan => chan.server === server && chan.name === name
)
) {
store.dispatch(select(server, name, true));
} else if (find(env.servers, srv => srv.host === server)) {
store.dispatch(select(server, null, true));
} else {
store.dispatch(updateSelection());
}
} else {
store.dispatch(updateSelection());
}
}
} else {
store.dispatch(replace('/connect'));
}
if (env.channels) {
store.dispatch({
type: socketActions.CHANNELS,
data: env.channels
});
}
if (env.users) {
store.dispatch({
type: socketActions.USERS,
...env.users
});
}
// Wait until wrapWidth gets initialized so that height calculations
// only happen once for these messages
when(store, getWrapWidth, () => {
if (env.messages) {
const { messages, server, to, next } = env.messages;
store.dispatch(addMessages(messages, server, to, false, next));
store.dispatch({
type: INIT,
settings: env.settings,
networks: env.networks,
channels: env.channels,
openDMs: env.openDMs,
users: env.users,
app: {
connectDefaults: env.defaults,
initialized: true,
hexIP: env.hexIP,
version: env.version
}
});
if (env.messages) {
// Wait until wrapWidth gets initialized so that height calculations
// only happen once for these messages
when(store, getWrapWidth, () => {
const { messages, network, to, next } = env.messages;
store.dispatch(addMessages(messages, network, to, false, next));
});
}
if (env.networks) {
when(store, getConnected, () =>
// Cache top channels for each network
env.networks.forEach(({ host }) =>
store.dispatch(searchChannels(host, ''))
)
);
}
}
/* eslint-disable no-underscore-dangle */
export default async function initialState(ctx) {
const env = await window.__init__;
ctx.socket.connect();

View File

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

View File

@ -1,25 +1,22 @@
import { socketAction } from 'state/actions';
import { setConnected } from 'state/app';
import { kicked } from 'state/channels';
import {
broadcast,
inform,
print,
addMessage,
addMessages
addMessages,
addEvent,
broadcastEvent
} from 'state/messages';
import { reconnect } from 'state/servers';
import { openModal } from 'state/modals';
import { reconnect } from 'state/networks';
import { select } from 'state/tab';
import { find, normalizeChannel } from 'utils';
import { find } from 'utils';
function withReason(message, reason) {
return message + (reason ? ` (${reason})` : '');
}
function findChannels(state, server, user) {
function findChannels(state, network, user) {
const channels = [];
Object.keys(state.channels[server]).forEach(channel => {
if (find(state.channels[server][channel].users, u => u.nick === user)) {
Object.keys(state.channels[network]).forEach(channel => {
if (find(state.channels[network][channel].users, u => u.nick === user)) {
channels.push(channel);
}
});
@ -33,67 +30,59 @@ export default function handleSocket({
}) {
const handlers = {
message(message) {
dispatch(addMessage(message, message.server, message.to));
dispatch(addMessage(message, message.network, message.to));
return false;
},
pm(message) {
dispatch(addMessage(message, message.server, message.from));
dispatch(addMessage(message, message.network, message.from));
return false;
},
messages({ messages, server, to, prepend, next }) {
dispatch(addMessages(messages, server, to, prepend, next));
messages({ messages, network, to, prepend, next }) {
dispatch(addMessages(messages, network, to, prepend, next));
return false;
},
join({ user, server, channels }) {
const state = getState();
const tab = state.tab.selected;
const [joinedChannel] = channels;
if (tab.server && tab.name) {
const { nick } = state.servers[tab.server];
if (
tab.server === server &&
nick === user &&
tab.name !== joinedChannel &&
normalizeChannel(tab.name) === normalizeChannel(joinedChannel)
) {
dispatch(select(server, joinedChannel));
}
join({ user, network, channels }) {
dispatch(addEvent(network, channels[0], 'join', user));
},
part({ user, network, channel, reason }) {
dispatch(addEvent(network, channel, 'part', user, reason));
},
quit({ user, network, reason }) {
const channels = findChannels(getState(), network, user);
dispatch(broadcastEvent(network, channels, 'quit', user, reason));
},
kick({ network, channel, sender, user, reason }) {
dispatch(kicked(network, channel, user));
dispatch(addEvent(network, channel, 'kick', user, sender, reason));
},
nick({ network, oldNick, newNick }) {
if (oldNick) {
const channels = findChannels(getState(), network, oldNick);
dispatch(broadcastEvent(network, channels, 'nick', oldNick, newNick));
}
dispatch(inform(`${user} joined the channel`, server, joinedChannel));
},
part({ user, server, channel, reason }) {
dispatch(
inform(withReason(`${user} left the channel`, reason), server, channel)
);
},
quit({ user, server, reason }) {
const channels = findChannels(getState(), server, user);
dispatch(broadcast(withReason(`${user} quit`, reason), server, channels));
},
nick({ server, oldNick, newNick }) {
const channels = findChannels(getState(), server, oldNick);
dispatch(
broadcast(`${oldNick} changed nick to ${newNick}`, server, channels)
);
},
topic({ server, channel, topic, nick }) {
topic({ network, channel, topic, nick }) {
if (nick) {
if (topic) {
dispatch(inform(`${nick} changed the topic to:`, server, channel));
dispatch(print(topic, server, channel));
} else {
dispatch(inform(`${nick} cleared the topic`, server, channel));
}
dispatch(addEvent(network, channel, 'topic', nick, topic));
}
},
motd({ content, server }) {
dispatch(addMessages(content.map(line => ({ content: line })), server));
motd({ content, network }) {
dispatch(
addMessages(
content.map(line => ({ content: line })),
network
)
);
return false;
},
whois(data) {
@ -109,34 +98,82 @@ export default function handleSocket({
`Server: ${data.server}`,
`Channels: ${data.channels}`
],
tab.server,
tab.network,
tab.name
)
);
return false;
},
print(message) {
const tab = getState().tab.selected;
dispatch(addMessage(message, tab.server, tab.name));
dispatch(addMessage(message, tab.network, tab.name));
return false;
},
connection_update({ server, errorType }) {
if (
errorType === 'verify' &&
window.confirm(
'The server is using a self-signed certificate, continue anyway?'
)
) {
error({ network, target, message }) {
const state = getState();
const tab = state.tab.selected;
if (network === tab.network) {
// Print it in the current channel if the error happened on
// the current network
target = tab.name;
} else if (!state.channels[network]?.[target]) {
// Print it the network tab if the target does not exist
target = null;
}
dispatch(
addMessage({ content: message, type: 'error' }, network, target)
);
return false;
},
connection_update({ network, errorType }) {
if (errorType === 'verify') {
dispatch(
reconnect(server, {
skipVerify: true
openModal('confirm', {
question:
'The network is using a self-signed certificate, continue anyway?',
onConfirm: () =>
dispatch(
reconnect(network, {
skipVerify: true
})
)
})
);
}
},
_connected(connected) {
dispatch(setConnected(connected));
dcc_send({ network, from, filename, size, url }) {
const networkName = getState().networks[network]?.name || network;
dispatch(
openModal('confirm', {
question: `${from} on ${networkName} is sending you (${size}): ${filename}`,
confirmation: 'Download',
onConfirm: () => {
const a = document.createElement('a');
a.href = url;
a.click();
}
})
);
}
};
const afterHandlers = {
channel_forward(forward) {
const { selected } = getState().tab;
if (
selected.network === forward.network &&
selected.name === forward.old
) {
dispatch(select(forward.network, forward.new, true));
}
}
};
@ -148,14 +185,12 @@ export default function handleSocket({
action = { ...data, type: socketAction(type) };
}
if (type in handlers) {
handlers[type](data);
}
if (type.charAt(0) === '_') {
if (handlers[type]?.(data) === false) {
return;
}
dispatch(action);
afterHandlers[type]?.(data);
});
}

View File

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

View File

@ -10,7 +10,6 @@ const smallScreen = 600;
export default function widthUpdates({ store }) {
when(store, getCharWidth, charWidth => {
window.messageIndent = 6 * charWidth;
const scrollBarWidth = measureScrollBarWidth();
let prevWrapWidth;

View File

@ -1,5 +1,5 @@
export default {
connect: '/connect',
settings: '/settings',
chat: '/:server(/:name)'
chat: '/:network(/:name)'
};

View File

@ -23,10 +23,8 @@ function registerValidSW(swUrl, config) {
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
if (config && config.onSuccess) {
config.onSuccess(registration);
}
} else if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
};

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import reducer, { compareUsers, getSortedChannels } from '../channels';
import { connect } from '../servers';
import { connect } from '../networks';
import * as actions from '../actions';
describe('channel reducer', () => {
@ -17,7 +17,7 @@ describe('channel reducer', () => {
state = reducer(state, {
type: actions.PART,
server: 'srv1',
network: 'srv1',
channels: ['chan1', 'chan3']
});
@ -38,7 +38,7 @@ describe('channel reducer', () => {
state = reducer(state, {
type: actions.socket.PART,
server: 'srv',
network: 'srv',
channel: 'chan1',
user: 'nick2'
});
@ -46,9 +46,13 @@ describe('channel reducer', () => {
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
},
chan2: {
name: 'chan2',
joined: true,
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
@ -61,6 +65,8 @@ describe('channel reducer', () => {
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
}
}
@ -74,16 +80,81 @@ describe('channel reducer', () => {
state = reducer(state, {
type: actions.socket.QUIT,
server: 'srv',
network: 'srv',
user: 'nick2'
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
},
chan2: {
name: 'chan2',
joined: true,
users: []
}
}
});
});
it('handles KICKED', () => {
let state = reducer(
undefined,
connect({
host: 'srv',
nick: 'nick2'
})
);
state = reducer(state, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, socket_join('srv', 'chan1', 'nick2'));
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.KICKED,
network: 'srv',
channel: 'chan2',
user: 'nick2',
self: true
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [
{ mode: '', nick: 'nick1', renderName: 'nick1' },
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
},
chan2: {
name: 'chan2',
joined: false,
users: []
}
}
});
state = reducer(state, {
type: actions.KICKED,
network: 'srv',
channel: 'chan1',
user: 'nick1'
});
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
},
chan2: {
name: 'chan2',
joined: false,
users: []
}
}
@ -97,7 +168,7 @@ describe('channel reducer', () => {
state = reducer(state, {
type: actions.socket.NICK,
server: 'srv',
network: 'srv',
oldNick: 'nick1',
newNick: 'nick3'
});
@ -105,12 +176,16 @@ describe('channel reducer', () => {
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [
{ mode: '', nick: 'nick3', renderName: 'nick3' },
{ mode: '', nick: 'nick2', renderName: 'nick2' }
]
},
chan2: {
name: 'chan2',
joined: true,
users: [{ mode: '', nick: 'nick2', renderName: 'nick2' }]
}
}
@ -118,9 +193,10 @@ describe('channel reducer', () => {
});
it('handles SOCKET_USERS', () => {
const state = reducer(undefined, {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, {
type: actions.socket.USERS,
server: 'srv',
network: 'srv',
channel: 'chan1',
users: ['user3', 'user2', '@user4', 'user1', '+user5']
});
@ -128,6 +204,8 @@ describe('channel reducer', () => {
expect(state).toEqual({
srv: {
chan1: {
name: 'chan1',
joined: true,
users: [
{ mode: '', nick: 'user3', renderName: 'user3' },
{ mode: '', nick: 'user2', renderName: 'user2' },
@ -141,18 +219,18 @@ describe('channel reducer', () => {
});
it('handles SOCKET_TOPIC', () => {
const state = reducer(undefined, {
let state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
state = reducer(state, {
type: actions.socket.TOPIC,
server: 'srv',
network: 'srv',
channel: 'chan1',
topic: 'the topic'
});
expect(state).toEqual({
expect(state).toMatchObject({
srv: {
chan1: {
topic: 'the topic',
users: []
topic: 'the topic'
}
}
});
@ -165,7 +243,7 @@ describe('channel reducer', () => {
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', ''));
expect(state).toEqual({
expect(state).toMatchObject({
srv: {
chan1: {
users: [
@ -183,7 +261,7 @@ describe('channel reducer', () => {
state = reducer(state, socket_mode('srv', 'chan1', 'nick2', 'o', ''));
state = reducer(state, socket_mode('srv', 'chan2', 'not_there', 'x', ''));
expect(state).toEqual({
expect(state).toMatchObject({
srv: {
chan1: {
users: [
@ -198,31 +276,31 @@ describe('channel reducer', () => {
});
});
it('handles SOCKET_CHANNELS', () => {
it('handles channels from INIT', () => {
const state = reducer(undefined, {
type: actions.socket.CHANNELS,
data: [
{ server: 'srv', name: 'chan1', topic: 'the topic' },
{ server: 'srv', name: 'chan2' },
{ server: 'srv2', name: 'chan1' }
type: actions.INIT,
channels: [
{ network: 'srv', name: 'chan1', topic: 'the topic' },
{ network: 'srv', name: 'chan2', joined: true },
{ network: 'srv2', name: 'chan1' }
]
});
expect(state).toEqual({
srv: {
chan1: { topic: 'the topic', users: [] },
chan2: { users: [] }
chan1: { name: 'chan1', topic: 'the topic', users: [] },
chan2: { name: 'chan2', joined: true, users: [] }
},
srv2: {
chan1: { users: [] }
chan1: { name: 'chan1', users: [] }
}
});
});
it('handles SOCKET_SERVERS', () => {
it('handles networks from INIT', () => {
const state = reducer(undefined, {
type: actions.socket.SERVERS,
data: [{ host: '127.0.0.1' }, { host: 'thehost' }]
type: actions.INIT,
networks: [{ host: '127.0.0.1' }, { host: 'thehost' }]
});
expect(state).toEqual({
@ -231,7 +309,7 @@ describe('channel reducer', () => {
});
});
it('optimistically adds the server on CONNECT', () => {
it('optimistically adds the network on CONNECT', () => {
const state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
@ -242,7 +320,7 @@ describe('channel reducer', () => {
});
});
it('removes the server on DISCONNECT', () => {
it('removes the network on DISCONNECT', () => {
let state = {
srv: {},
srv2: {}
@ -250,7 +328,7 @@ describe('channel reducer', () => {
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv2'
network: 'srv2'
});
expect(state).toEqual({
@ -259,19 +337,19 @@ describe('channel reducer', () => {
});
});
function socket_join(server, channel, user) {
function socket_join(network, channel, user) {
return {
type: actions.socket.JOIN,
server,
network,
user,
channels: [channel]
};
}
function socket_mode(server, channel, user, add, remove) {
function socket_mode(network, channel, user, add, remove) {
return {
type: actions.socket.MODE,
server,
network,
channel,
user,
add,
@ -306,25 +384,42 @@ describe('compareUsers()', () => {
});
describe('getSortedChannels', () => {
it('sorts servers and channels', () => {
it('sorts networks and channels', () => {
expect(
getSortedChannels({
channels: {
'bob.com': {},
'127.0.0.1': {
'#chan1': {
name: '#chan1',
users: [],
topic: 'cake'
},
'#pie': {},
'##apples': {}
'#pie': {
name: '#pie'
},
'##apples': {
name: '##apples'
}
}
}
})
).toEqual([
{
address: '127.0.0.1',
channels: ['##apples', '#chan1', '#pie']
channels: [
{
name: '##apples'
},
{
name: '#chan1',
users: [],
topic: 'cake'
},
{
name: '#pie'
}
]
},
{
address: 'bob.com',

View File

@ -1,12 +1,13 @@
import reducer, { broadcast, getMessageTab } from '../messages';
import * as actions from '../actions';
import appReducer from '../app';
import { unix } from 'utils';
describe('message reducer', () => {
it('adds the message on ADD_MESSAGE', () => {
const state = reducer(undefined, {
type: actions.ADD_MESSAGE,
server: 'srv',
network: 'srv',
tab: '#chan1',
message: {
from: 'foo',
@ -19,7 +20,7 @@ describe('message reducer', () => {
'#chan1': [
{
from: 'foo',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
}
]
}
@ -29,7 +30,7 @@ describe('message reducer', () => {
it('adds all the messages on ADD_MESSAGES', () => {
const state = reducer(undefined, {
type: actions.ADD_MESSAGES,
server: 'srv',
network: 'srv',
tab: '#chan1',
messages: [
{
@ -53,17 +54,17 @@ describe('message reducer', () => {
'#chan1': [
{
from: 'foo',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
},
{
from: 'bar',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
}
],
'#chan2': [
{
from: 'foo',
content: 'msg'
content: [{ type: 'text', text: 'msg' }]
}
]
}
@ -79,10 +80,13 @@ describe('message reducer', () => {
state = reducer(state, {
type: actions.ADD_MESSAGES,
server: 'srv',
network: 'srv',
tab: '#chan1',
prepend: true,
messages: [{ id: 1 }, { id: 2 }]
messages: [
{ id: 1, date: new Date() },
{ id: 2, date: new Date() }
]
});
expect(state).toMatchObject({
@ -92,6 +96,90 @@ describe('message reducer', () => {
});
});
it('adds date markers when prepending messages', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1990, 0, 3) }]
}
};
state = reducer(state, {
type: actions.ADD_MESSAGES,
network: 'srv',
tab: '#chan1',
prepend: true,
messages: [
{ id: 1, time: unix(new Date(1990, 0, 1)) },
{ id: 2, time: unix(new Date(1990, 0, 2)) }
]
});
expect(state).toMatchObject({
srv: {
'#chan1': [
{ id: 1 },
{ type: 'date' },
{ id: 2 },
{ type: 'date' },
{ id: 0 }
]
}
});
});
it('adds a date marker when adding a message', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1999, 0, 1) }]
}
};
state = reducer(state, {
type: actions.ADD_MESSAGE,
network: 'srv',
tab: '#chan1',
message: { id: 1, date: new Date(1990, 0, 2) }
});
expect(state).toMatchObject({
srv: {
'#chan1': [{ id: 0 }, { type: 'date' }, { id: 1 }]
}
});
});
it('adds date markers when adding messages', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1990, 0, 1) }]
}
};
state = reducer(state, {
type: actions.ADD_MESSAGES,
network: 'srv',
tab: '#chan1',
messages: [
{ id: 1, time: unix(new Date(1990, 0, 2)) },
{ id: 2, time: unix(new Date(1990, 0, 3)) },
{ id: 3, time: unix(new Date(1990, 0, 3)) }
]
});
expect(state).toMatchObject({
srv: {
'#chan1': [
{ id: 0 },
{ type: 'date' },
{ id: 1 },
{ type: 'date' },
{ id: 2 },
{ id: 3 }
]
}
});
});
it('adds messages to the correct tabs when broadcasting', () => {
let state = {
app: appReducer(undefined, { type: '' })
@ -109,12 +197,16 @@ describe('message reducer', () => {
expect(messages.srv).not.toHaveProperty('srv');
expect(messages.srv['#chan1']).toHaveLength(1);
expect(messages.srv['#chan1'][0].content).toBe('test');
expect(messages.srv['#chan1'][0].content).toMatchObject([
{ type: 'text', text: 'test' }
]);
expect(messages.srv['#chan3']).toHaveLength(1);
expect(messages.srv['#chan3'][0].content).toBe('test');
expect(messages.srv['#chan3'][0].content).toMatchObject([
{ type: 'text', text: 'test' }
]);
});
it('deletes all messages related to server when disconnecting', () => {
it('deletes all messages related to network when disconnecting', () => {
let state = {
srv: {
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
@ -127,7 +219,7 @@ describe('message reducer', () => {
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv'
network: 'srv'
});
expect(state).toEqual({
@ -150,7 +242,7 @@ describe('message reducer', () => {
state = reducer(state, {
type: actions.PART,
server: 'srv',
network: 'srv',
channels: ['#chan1']
});
@ -163,6 +255,33 @@ describe('message reducer', () => {
}
});
});
it('deletes direct messages when closing a direct message tab', () => {
let state = {
srv: {
bob: [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [{ content: 'msg' }]
}
};
state = reducer(state, {
type: actions.CLOSE_PRIVATE_CHAT,
network: 'srv',
nick: 'bob'
});
expect(state).toEqual({
srv: {
'#chan2': [{ content: 'msg' }]
},
srv2: {
'#chan1': [{ content: 'msg' }]
}
});
});
});
describe('getMessageTab()', () => {

View File

@ -1,8 +1,8 @@
import reducer, { connect, setServerName } from '../servers';
import reducer, { connect, setNetworkName } from '../networks';
import * as actions from '../actions';
describe('server reducer', () => {
it('adds the server on CONNECT', () => {
describe('network reducer', () => {
it('adds the network on CONNECT', () => {
let state = reducer(
undefined,
connect({ host: '127.0.0.1', nick: 'nick' })
@ -13,10 +13,9 @@ describe('server reducer', () => {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
connected: false,
error: null,
features: {}
}
});
@ -27,10 +26,9 @@ describe('server reducer', () => {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
connected: false,
error: null,
features: {}
}
});
@ -44,24 +42,22 @@ describe('server reducer', () => {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
connected: false,
error: null,
features: {}
},
'127.0.0.2': {
name: 'srv',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: null
}
connected: false,
error: null,
features: {}
}
});
});
it('removes the server on DISCONNECT', () => {
it('removes the network on DISCONNECT', () => {
let state = {
srv: {},
srv2: {}
@ -69,7 +65,7 @@ describe('server reducer', () => {
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv2'
network: 'srv2'
});
expect(state).toEqual({
@ -77,14 +73,14 @@ describe('server reducer', () => {
});
});
it('handles SET_SERVER_NAME', () => {
it('handles SET_NETWORK_NAME', () => {
let state = {
srv: {
name: 'cake'
}
};
state = reducer(state, setServerName('pie', 'srv'));
state = reducer(state, setNetworkName('pie', 'srv'));
expect(state).toEqual({
srv: {
@ -100,7 +96,7 @@ describe('server reducer', () => {
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
network: '127.0.0.1',
nick: 'nick2',
editing: true
});
@ -121,13 +117,13 @@ describe('server reducer', () => {
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
network: '127.0.0.1',
nick: 'nick2',
editing: true
});
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
network: '127.0.0.1',
nick: ''
});
@ -147,7 +143,7 @@ describe('server reducer', () => {
);
state = reducer(state, {
type: actions.socket.NICK,
server: '127.0.0.1',
network: '127.0.0.1',
oldNick: 'nick',
newNick: 'nick2'
});
@ -168,13 +164,13 @@ describe('server reducer', () => {
);
state = reducer(state, {
type: actions.SET_NICK,
server: '127.0.0.1',
network: '127.0.0.1',
nick: 'nick2',
editing: true
});
state = reducer(state, {
type: actions.socket.NICK_FAIL,
server: '127.0.0.1'
network: '127.0.0.1'
});
expect(state).toMatchObject({
@ -186,25 +182,21 @@ describe('server reducer', () => {
});
});
it('adds the servers on SOCKET_SERVERS', () => {
it('adds the networks on INIT', () => {
let state = reducer(undefined, {
type: actions.socket.SERVERS,
data: [
type: actions.INIT,
networks: [
{
host: '127.0.0.1',
name: 'stuff',
nick: 'nick',
status: {
connected: true
}
connected: true
},
{
host: '127.0.0.2',
name: 'stuffz',
nick: 'nick2',
status: {
connected: false
}
connected: false
}
]
});
@ -214,17 +206,15 @@ describe('server reducer', () => {
name: 'stuff',
nick: 'nick',
editedNick: null,
status: {
connected: true
}
connected: true,
features: {}
},
'127.0.0.2': {
name: 'stuffz',
nick: 'nick2',
editedNick: null,
status: {
connected: false
}
connected: false,
features: {}
}
});
});
@ -236,7 +226,7 @@ describe('server reducer', () => {
);
state = reducer(state, {
type: actions.socket.CONNECTION_UPDATE,
server: '127.0.0.1',
network: '127.0.0.1',
connected: true
});
@ -245,15 +235,14 @@ describe('server reducer', () => {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: true
}
connected: true,
features: {}
}
});
state = reducer(state, {
type: actions.socket.CONNECTION_UPDATE,
server: '127.0.0.1',
network: '127.0.0.1',
connected: false,
error: 'Bad stuff happened'
});
@ -263,10 +252,9 @@ describe('server reducer', () => {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: false,
error: 'Bad stuff happened'
}
connected: false,
error: 'Bad stuff happened',
features: {}
}
});
});

View File

@ -7,17 +7,17 @@ describe('tab reducer', () => {
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
expect(state).toEqual({
selected: { server: 'srv', name: '#chan' },
history: [{ server: 'srv', name: '#chan' }]
selected: { network: 'srv', name: '#chan' },
history: [{ network: 'srv', name: '#chan' }]
});
state = reducer(state, setSelectedTab('srv', 'user1'));
expect(state).toEqual({
selected: { server: 'srv', name: 'user1' },
selected: { network: 'srv', name: 'user1' },
history: [
{ server: 'srv', name: '#chan' },
{ server: 'srv', name: 'user1' }
{ network: 'srv', name: '#chan' },
{ network: 'srv', name: 'user1' }
]
});
});
@ -30,15 +30,15 @@ describe('tab reducer', () => {
state = reducer(state, {
type: actions.PART,
server: 'srv',
network: 'srv',
channels: ['#chan']
});
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
selected: { network: 'srv', name: '#chan3' },
history: [
{ server: 'srv1', name: 'bob' },
{ server: 'srv', name: '#chan3' }
{ network: 'srv1', name: 'bob' },
{ network: 'srv', name: '#chan3' }
]
});
});
@ -51,21 +51,21 @@ describe('tab reducer', () => {
state = reducer(state, {
type: actions.CLOSE_PRIVATE_CHAT,
server: 'srv1',
network: 'srv1',
nick: 'bob'
});
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
selected: { network: 'srv', name: '#chan3' },
history: [
{ server: 'srv', name: '#chan' },
{ server: 'srv', name: '#chan' },
{ server: 'srv', name: '#chan3' }
{ network: 'srv', name: '#chan' },
{ network: 'srv', name: '#chan' },
{ network: 'srv', name: '#chan3' }
]
});
});
it('removes all tabs related to server from history on DISCONNECT', () => {
it('removes all tabs related to network from history on DISCONNECT', () => {
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
state = reducer(state, setSelectedTab('srv1', 'bob'));
state = reducer(state, setSelectedTab('srv', '#chan'));
@ -73,38 +73,42 @@ describe('tab reducer', () => {
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv'
network: 'srv'
});
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
history: [{ server: 'srv1', name: 'bob' }]
selected: { network: 'srv', name: '#chan3' },
history: [{ network: 'srv1', name: 'bob' }]
});
});
it('clears the tab when navigating to a non-tab page', () => {
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
state = reducer(state, locationChanged('settings'));
state = reducer(state, locationChanged('settings', {}, {}));
expect(state).toEqual({
selected: {},
history: [{ server: 'srv', name: '#chan' }]
history: [{ network: 'srv', name: '#chan' }]
});
});
it('selects the tab and adds it to history when navigating to a tab', () => {
const state = reducer(
undefined,
locationChanged('chat', {
server: 'srv',
name: '#chan'
})
locationChanged(
'chat',
{
network: 'srv',
name: '#chan'
},
{}
)
);
expect(state).toEqual({
selected: { server: 'srv', name: '#chan' },
history: [{ server: 'srv', name: '#chan' }]
selected: { network: 'srv', name: '#chan' },
history: [{ network: 'srv', name: '#chan' }]
});
});
});

View File

@ -1,11 +1,15 @@
export const INIT = 'INIT';
export const APP_SET = 'APP_SET';
export const INVITE = 'INVITE';
export const JOIN = 'JOIN';
export const KICK = 'KICK';
export const KICKED = 'KICKED';
export const PART = 'PART';
export const SET_TOPIC = 'SET_TOPIC';
export const CHANNEL_SEARCH = 'CHANNEL_SEARCH';
export const INPUT_HISTORY_ADD = 'INPUT_HISTORY_ADD';
export const INPUT_HISTORY_DECREMENT = 'INPUT_HISTORY_DECREMENT';
export const INPUT_HISTORY_INCREMENT = 'INPUT_HISTORY_INCREMENT';
@ -19,6 +23,9 @@ export const FETCH_MESSAGES = 'FETCH_MESSAGES';
export const RAW = 'RAW';
export const UPDATE_MESSAGE_HEIGHT = 'UPDATE_MESSAGE_HEIGHT';
export const OPEN_MODAL = 'OPEN_MODAL';
export const CLOSE_MODAL = 'CLOSE_MODAL';
export const CLOSE_PRIVATE_CHAT = 'CLOSE_PRIVATE_CHAT';
export const OPEN_PRIVATE_CHAT = 'OPEN_PRIVATE_CHAT';
@ -30,7 +37,7 @@ export const CONNECT = 'CONNECT';
export const DISCONNECT = 'DISCONNECT';
export const RECONNECT = 'RECONNECT';
export const SET_NICK = 'SET_NICK';
export const SET_SERVER_NAME = 'SET_SERVER_NAME';
export const SET_NETWORK_NAME = 'SET_NETWORK_NAME';
export const WHOIS = 'WHOIS';
export const SET_CERT = 'SET_CERT';
@ -60,18 +67,22 @@ function createSocketActions(types) {
export const socket = createSocketActions([
'cert_fail',
'cert_success',
'channels',
'channel_forward',
'channel_search',
'connected',
'connection_update',
'error',
'features',
'join',
'message',
'mode',
'nick_fail',
'nick',
'part',
'kick',
'pm',
'quit',
'search',
'servers',
'topic',
'users'
]);

View File

@ -9,13 +9,14 @@ export const getWindowWidth = state => state.app.windowWidth;
export const getConnectDefaults = state => state.app.connectDefaults;
const initialState = {
connected: true,
connected: false,
wrapWidth: 0,
charWidth: 0,
windowWidth: 0,
connectDefaults: {
name: '',
address: '',
host: '',
port: '',
channels: [],
ssl: false,
password: false,
@ -29,13 +30,25 @@ const initialState = {
export default createReducer(initialState, {
[actions.APP_SET](state, { key, value }) {
state[key] = value;
if (typeof key === 'object') {
Object.assign(state, key);
} else {
state[key] = value;
}
},
[actions.socket.CONNECTED](state, { connected }) {
state.connected = connected;
},
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
state.wrapWidth = action.wrapWidth;
state.charWidth = action.charWidth;
state.windowWidth = action.windowWidth;
},
[actions.INIT](state, { app }) {
Object.assign(state, app);
}
});
@ -47,14 +60,6 @@ export function appSet(key, value) {
};
}
export function setConnected(connected) {
return appSet('connected', connected);
}
export function setCharWidth(width) {
return appSet('charWidth', width);
}
export function setConnectDefaults(defaults) {
return appSet('connectDefaults', defaults);
}

View File

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

View File

@ -2,7 +2,7 @@ import { createSelector } from 'reselect';
import get from 'lodash/get';
import sortBy from 'lodash/sortBy';
import createReducer from 'utils/createReducer';
import { find, findIndex } from 'utils';
import { trimPrefixChar, find, findIndex } from 'utils';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
@ -56,13 +56,18 @@ function removeUser(users, nick) {
}
}
function init(state, server, channel) {
if (!state[server]) {
state[server] = {};
function init(state, network, channel) {
if (!state[network]) {
state[network] = {};
}
if (channel && !state[server][channel]) {
state[server][channel] = { users: [] };
if (channel && !state[network][channel]) {
state[network][channel] = {
name: channel,
users: [],
joined: false
};
}
return state[network][channel];
}
export function compareUsers(a, b) {
@ -91,24 +96,22 @@ export function compareUsers(a, b) {
export const getChannels = state => state.channels;
export const getSortedChannels = createSelector(
getChannels,
channels =>
sortBy(
Object.keys(channels).map(server => ({
address: server,
channels: sortBy(Object.keys(channels[server]), channel =>
channel.toLowerCase()
)
})),
server => server.address.toLowerCase()
)
export const getSortedChannels = createSelector(getChannels, channels =>
sortBy(
Object.keys(channels).map(network => ({
address: network,
channels: sortBy(channels[network], channel =>
trimPrefixChar(channel.name, '#').toLowerCase()
)
})),
network => network.address.toLowerCase()
)
);
export const getSelectedChannel = createSelector(
getSelectedTab,
getChannels,
(tab, channels) => get(channels, [tab.server, tab.name])
(tab, channels) => get(channels, [tab.network, tab.name])
);
export const getSelectedChannelUsers = createSelector(
@ -124,32 +127,53 @@ export const getSelectedChannelUsers = createSelector(
export default createReducer(
{},
{
[actions.PART](state, { server, channels }) {
channels.forEach(channel => delete state[server][channel]);
[actions.JOIN](state, { network, channels }) {
channels.forEach(channel => init(state, network, channel));
},
[actions.socket.JOIN](state, { server, channels, user }) {
[actions.PART](state, { network, channels }) {
channels.forEach(channel => delete state[network][channel]);
},
[actions.socket.JOIN](state, { network, channels, user }) {
const channel = channels[0];
init(state, server, channel);
state[server][channel].users.push(createUser(user));
const chan = init(state, network, channel);
chan.name = channel;
chan.joined = true;
chan.users.push(createUser(user));
},
[actions.socket.PART](state, { server, channel, user }) {
if (state[server][channel]) {
removeUser(state[server][channel].users, user);
[actions.socket.CHANNEL_FORWARD](state, action) {
init(state, action.network, action.new);
delete state[action.network][action.old];
},
[actions.socket.PART](state, { network, channel, user }) {
if (state[network][channel]) {
removeUser(state[network][channel].users, user);
}
},
[actions.socket.QUIT](state, { server, user }) {
Object.keys(state[server]).forEach(channel => {
removeUser(state[server][channel].users, user);
[actions.socket.QUIT](state, { network, user }) {
Object.keys(state[network]).forEach(channel => {
removeUser(state[network][channel].users, user);
});
},
[actions.socket.NICK](state, { server, oldNick, newNick }) {
Object.keys(state[server]).forEach(channel => {
[actions.KICKED](state, { network, channel, user, self }) {
const chan = state[network][channel];
if (self) {
chan.joined = false;
chan.users = [];
} else {
removeUser(chan.users, user);
}
},
[actions.socket.NICK](state, { network, oldNick, newNick }) {
Object.keys(state[network]).forEach(channel => {
const user = find(
state[server][channel].users,
state[network][channel].users,
u => u.nick === oldNick
);
if (user) {
@ -159,18 +183,8 @@ export default createReducer(
});
},
[actions.socket.USERS](state, { server, channel, users }) {
init(state, server, channel);
state[server][channel].users = users.map(nick => loadUser(nick));
},
[actions.socket.TOPIC](state, { server, channel, topic }) {
init(state, server, channel);
state[server][channel].topic = topic;
},
[actions.socket.MODE](state, { server, channel, user, remove, add }) {
const u = find(state[server][channel].users, v => v.nick === user);
[actions.socket.MODE](state, { network, channel, user, remove, add }) {
const u = find(state[network][channel].users, v => v.nick === user);
if (u) {
if (remove) {
let j = remove.length;
@ -187,18 +201,31 @@ export default createReducer(
}
},
[actions.socket.CHANNELS](state, { data }) {
if (data) {
data.forEach(({ server, name, topic }) => {
init(state, server, name);
state[server][name].topic = topic;
});
}
[actions.socket.TOPIC](state, { network, channel, topic }) {
state[network][channel].topic = topic;
},
[actions.socket.SERVERS](state, { data }) {
if (data) {
data.forEach(({ host }) => init(state, host));
[actions.socket.USERS](state, { network, channel, users }) {
state[network][channel].users = users.map(nick => loadUser(nick));
},
[actions.INIT](state, { networks, channels, users }) {
if (networks) {
networks.forEach(({ host }) => init(state, host));
}
if (channels) {
channels.forEach(({ network, name, topic, joined }) => {
const chan = init(state, network, name);
chan.joined = joined;
chan.topic = topic;
});
}
if (users) {
state[users.network][users.channel].users = users.users.map(nick =>
loadUser(nick)
);
}
},
@ -206,74 +233,100 @@ export default createReducer(
init(state, host);
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
[actions.DISCONNECT](state, { network }) {
delete state[network];
}
}
);
export function join(channels, server) {
export function join(channels, network, selectFirst = true) {
return {
type: actions.JOIN,
channels,
server,
network,
selectFirst,
socket: {
type: 'join',
data: { channels, server }
data: { channels, network }
}
};
}
export function part(channels, server) {
return dispatch => {
dispatch({
export function part(channels, network) {
return (dispatch, getState) => {
const action = {
type: actions.PART,
channels,
server,
socket: {
network
};
const state = getState().channels[network];
const joined = channels.filter(c => state[c] && state[c].joined);
if (joined.length > 0) {
action.socket = {
type: 'part',
data: { channels, server }
}
});
data: {
channels: joined,
network
}
};
}
dispatch(action);
dispatch(updateSelection());
};
}
export function invite(user, channel, server) {
export function invite(user, channel, network) {
return {
type: actions.INVITE,
user,
channel,
server,
network,
socket: {
type: 'invite',
data: { user, channel, server }
data: { user, channel, network }
}
};
}
export function kick(user, channel, server) {
export function kick(user, channel, network) {
return {
type: actions.KICK,
user,
channel,
server,
network,
socket: {
type: 'kick',
data: { user, channel, server }
data: { user, channel, network }
}
};
}
export function setTopic(topic, channel, server) {
export function kicked(network, channel, user) {
return (dispatch, getState) => {
const nick = getState().networks[network]?.nick;
dispatch({
type: actions.KICKED,
network,
channel,
user,
self: nick === user
});
};
}
export function setTopic(topic, channel, network) {
return {
type: actions.SET_TOPIC,
topic,
channel,
server,
network,
socket: {
type: 'topic',
data: { topic, channel, server }
data: { topic, channel, network }
}
};
}

View File

@ -1,11 +1,13 @@
import { combineReducers } from 'redux';
import app from './app';
import channels from './channels';
import channelSearch from './channelSearch';
import input from './input';
import messages from './messages';
import modals from './modals';
import networks from './networks';
import privateChats from './privateChats';
import search from './search';
import servers from './servers';
import settings from './settings';
import tab from './tab';
import ui from './ui';
@ -18,11 +20,13 @@ export default function createReducer(router) {
router,
app,
channels,
channelSearch,
input,
messages,
modals,
networks,
privateChats,
search,
servers,
settings,
tab,
ui

View File

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

47
client/js/state/modals.js Normal file
View File

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

229
client/js/state/networks.js Normal file
View File

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

View File

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

View File

@ -18,15 +18,15 @@ export default createReducer(initialState, {
}
});
export function searchMessages(server, channel, phrase) {
export function searchMessages(network, channel, phrase) {
return {
type: actions.SEARCH_MESSAGES,
server,
network,
channel,
phrase,
socket: {
type: 'search',
data: { server, channel, phrase }
data: { network, channel, phrase }
}
};
}

View File

@ -1,11 +1,11 @@
import { createSelector } from 'reselect';
import get from 'lodash/get';
import { getServers } from './servers';
import { getNetworks } from './networks';
import { getSelectedTab } from './tab';
// eslint-disable-next-line import/prefer-default-export
export const getSelectedTabTitle = createSelector(
getSelectedTab,
getServers,
(tab, servers) => tab.name || get(servers, [tab.server, 'name'])
getNetworks,
(tab, networks) => tab.name || get(networks, [tab.network, 'name'])
);

View File

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

View File

@ -1,4 +1,3 @@
import assign from 'lodash/assign';
import createReducer from 'utils/createReducer';
import * as actions from './actions';
@ -41,10 +40,14 @@ export default createReducer(
[actions.SETTINGS_SET](state, { key, value, settings }) {
if (settings) {
assign(state, settings);
Object.assign(state, settings);
} else {
state[key] = value;
}
},
[actions.INIT](state, { settings }) {
return settings;
}
}
);
@ -80,7 +83,7 @@ export function setCert(fileName, cert) {
return {
type: actions.SET_CERT,
fileName,
cert: cert
cert
};
}
@ -88,7 +91,7 @@ export function setKey(fileName, key) {
return {
type: actions.SET_KEY,
fileName,
key: key
key
};
}

View File

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

View File

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

View File

@ -26,7 +26,7 @@ export default class Socket {
this.ws.onopen = () => {
this.connected = true;
this.emit('_connected', true);
this.emit('connected', { connected: true });
clearTimeout(this.timeoutConnect);
this.backoff.reset();
this.setTimeoutPing();
@ -35,7 +35,7 @@ export default class Socket {
this.ws.onclose = () => {
if (this.connected) {
this.connected = false;
this.emit('_connected', false);
this.emit('connected', { connected: false });
}
clearTimeout(this.timeoutConnect);
clearTimeout(this.timeoutPing);

View File

@ -1,9 +1,19 @@
import React from 'react';
import TestRenderer from 'react-test-renderer';
import { isChannel, isValidNick, isValidChannel, isValidUsername } from '..';
import {
trimPrefixChar,
isChannel,
isValidNick,
isValidChannel,
isValidUsername
} from '..';
import linkify from '../linkify';
const render = el => TestRenderer.create(el).toJSON();
describe('trimPrefixChar()', () => {
it('trims prefix characters', () => {
expect(trimPrefixChar('##chan', '#')).toBe('chan');
expect(trimPrefixChar('#chan', '#')).toBe('chan');
expect(trimPrefixChar('chan', '#')).toBe('chan');
});
});
describe('isChannel()', () => {
it('it handles strings', () => {
@ -81,21 +91,31 @@ describe('isValidUsername()', () => {
describe('linkify()', () => {
const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href);
const linkTo = href =>
render(
<a href={proto(href)} rel="noopener noreferrer" target="_blank">
{href}
</a>
);
const linkTo = href => ({
type: 'link',
url: proto(href),
text: href
});
const buildText = arr => {
for (let i = 0; i < arr.length; i++) {
if (typeof arr[i] === 'string') {
arr[i] = {
type: 'text',
text: arr[i]
};
}
}
return arr;
};
it('returns the arg when no matches are found', () =>
[null, undefined, 10, false, true, 'just some text', ''].forEach(input =>
expect(linkify(input)).toBe(input)
it('returns a text block when no matches are found', () =>
['just some text', ''].forEach(input =>
expect(linkify(input)).toStrictEqual([{ type: 'text', text: input }])
));
it('linkifies text', () =>
Object.entries({
'google.com': linkTo('google.com'),
'google.com': [linkTo('google.com')],
'google.com stuff': [linkTo('google.com'), ' stuff'],
'cake google.com stuff': ['cake ', linkTo('google.com'), ' stuff'],
'cake google.com stuff https://google.com': [
@ -115,6 +135,6 @@ describe('linkify()', () => {
'google.com ': [linkTo('google.com'), ' '],
'/google.com?': ['/', linkTo('google.com'), '?']
}).forEach(([input, expected]) =>
expect(render(linkify(input))).toEqual(expected)
expect(linkify(input)).toEqual(buildText(expected))
));
});

View File

@ -1,25 +1,5 @@
/* eslint-disable no-bitwise */
import { hsluvToHex } from 'hsluv';
//
// github.com/sindresorhus/fnv1a
//
const OFFSET_BASIS_32 = 2166136261;
const fnv1a = string => {
let hash = OFFSET_BASIS_32;
for (let i = 0; i < string.length; i++) {
hash ^= string.charCodeAt(i);
// 32-bit FNV prime: 2**24 + 2**8 + 0x93 = 16777619
// Using bitshift for accuracy and performance. Numbers in JS suck.
hash +=
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
};
import fnv1a from '@sindresorhus/fnv1a';
const colors = [];

356
client/js/utils/colorify.js Normal file
View File

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

View File

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

View File

@ -3,17 +3,6 @@ import padStart from 'lodash/padStart';
export { findBreakpoints, messageHeight } from './messageHeight';
export { default as linkify } from './linkify';
export function normalizeChannel(channel) {
if (channel.indexOf('#') !== 0) {
return channel;
}
return channel
.split('#')
.join('')
.toLowerCase();
}
export function isChannel(name) {
// TODO: Handle other channel types
if (typeof name === 'object') {
@ -22,17 +11,17 @@ export function isChannel(name) {
return typeof name === 'string' && name[0] === '#';
}
export function stringifyTab(server, name) {
if (typeof server === 'object') {
if (server.name) {
return `${server.server};${server.name}`;
export function stringifyTab(network, name) {
if (typeof network === 'object') {
if (network.name) {
return `${network.network};${network.name}`;
}
return server.server;
return network.network;
}
if (name) {
return `${server};${name}`;
return `${network};${name}`;
}
return server;
return network;
}
function isString(s, maxLength) {
@ -45,6 +34,26 @@ function isString(s, maxLength) {
return true;
}
export function isDM({ from, to }) {
return !to && from?.indexOf('.') === -1 && !isChannel(from);
}
export function trimPrefixChar(str, char) {
if (!isString(str)) {
return str;
}
let start = 0;
while (str[start] === char) {
start++;
}
if (start > 0) {
return str.slice(start);
}
return str;
}
// RFC 2812
// nickname = ( letter / special ) *( letter / digit / special / "-" )
// letter = A-Z / a-z
@ -124,6 +133,10 @@ export function isValidUsername(username) {
}
export function isInt(i, min, max) {
if (typeof i === 'string') {
i = parseInt(i, 10);
}
if (i < min || i > max || Math.floor(i) !== i) {
return false;
}
@ -137,6 +150,16 @@ export function timestamp(date = new Date()) {
return `${h}:${m}`;
}
const dateFmt = new Intl.DateTimeFormat(window.navigator.language);
export const formatDate = dateFmt.format;
export function unix(date) {
if (date) {
return Math.floor(date.getTime() / 1000);
}
return Math.floor(Date.now() / 1000);
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
@ -167,7 +190,7 @@ export function measureScrollBarWidth() {
}
export function findIndex(arr, pred) {
if (!arr) {
if (!Array.isArray(arr) || typeof pred !== 'function') {
return -1;
}
@ -187,3 +210,17 @@ export function find(arr, pred) {
}
return null;
}
export function count(arr, pred) {
if (!Array.isArray(arr) || typeof pred !== 'function') {
return 0;
}
let c = 0;
for (let i = 0; i < arr.length; i++) {
if (pred(arr[i])) {
c++;
}
}
return c;
}

View File

@ -1,16 +1,44 @@
import Autolinker from 'autolinker';
import React from 'react';
const autolinker = new Autolinker({
stripPrefix: false,
stripTrailingSlash: false
});
function pushText(arr, text) {
const last = arr[arr.length - 1];
if (last?.type === 'text') {
last.text += text;
} else {
arr.push({
type: 'text',
text
});
}
}
function pushLink(arr, url, text) {
arr.push({
type: 'link',
url,
text
});
}
export default function linkify(text) {
if (typeof text !== 'string') {
return text;
}
let matches = autolinker.parseText(text);
if (matches.length === 0) {
return text;
return [
{
type: 'text',
text
}
];
}
const result = [];
@ -22,46 +50,27 @@ export default function linkify(text) {
if (match.getType() === 'url') {
if (match.offset > pos) {
if (typeof result[result.length - 1] === 'string') {
result[result.length - 1] += text.slice(pos, match.offset);
} else {
result.push(text.slice(pos, match.offset));
}
pushText(result, text.slice(pos, match.offset));
}
result.push(
<a
target="_blank"
rel="noopener noreferrer"
href={match.getAnchorHref()}
key={i}
>
{match.matchedText}
</a>
);
} else if (typeof result[result.length - 1] === 'string') {
result[result.length - 1] += text.slice(
pos,
match.offset + match.matchedText.length
);
pushLink(result, match.getAnchorHref(), match.matchedText);
} else {
result.push(text.slice(pos, match.offset + match.matchedText.length));
pushText(
result,
text.slice(pos, match.offset + match.matchedText.length)
);
}
pos = match.offset + match.matchedText.length;
}
if (pos < text.length) {
if (typeof result[result.length - 1] === 'string') {
result[result.length - 1] += text.slice(pos);
if (result[result.length - 1]?.type === 'text') {
result[result.length - 1].text += text.slice(pos);
} else {
result.push(text.slice(pos));
pushText(result, text.slice(pos));
}
}
if (result.length === 1) {
return result[0];
}
return result;
}

View File

@ -2,20 +2,27 @@ const lineHeight = 24;
const userListWidth = 200;
const smallScreen = 600;
export function findBreakpoints(text) {
export function findBreakpoints(blocks) {
const breakpoints = [];
let length = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
for (let j = 0; j < blocks.length; j++) {
const {text} = blocks[j];
if (char === ' ') {
breakpoints.push({ end: i, next: i + 1 });
} else if (char === '-' && i !== text.length - 1) {
breakpoints.push({ end: i + 1, next: i + 1 });
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
if (char === ' ') {
breakpoints.push({ end: length + i, next: length + i + 1 });
} else if (i !== text.length - 1 && (char === '-' || char === '?')) {
breakpoints.push({ end: length + i + 1, next: length + i + 1 });
}
}
length += text.length;
}
return breakpoints;
return [breakpoints, length];
}
export function messageHeight(

View File

@ -1,18 +1,26 @@
import createHistory from 'history/createBrowserHistory';
import history from 'history/browser';
import UrlPattern from 'url-pattern';
const history = createHistory();
export const LOCATION_CHANGED = 'ROUTER_LOCATION_CHANGED';
export const PUSH = 'ROUTER_PUSH';
export const REPLACE = 'ROUTER_REPLACE';
export function locationChanged(route, params, location) {
Object.keys(params).forEach(key => {
params[key] = decodeURIComponent(params[key]);
});
const query = {};
new URLSearchParams(location.search).forEach((value, key) => {
query[key] = value;
});
return {
type: LOCATION_CHANGED,
route,
params,
location
query,
path: decodeURIComponent(location.pathname)
};
}
@ -30,13 +38,9 @@ export function replace(path) {
};
}
export function routeReducer(state = {}, action) {
if (action.type === LOCATION_CHANGED) {
return {
route: action.route,
params: action.params,
location: action.location
};
export function routeReducer(state = {}, { type, ...action }) {
if (type === LOCATION_CHANGED) {
return action;
}
return state;
@ -46,7 +50,7 @@ export function routeMiddleware() {
return next => action => {
switch (action.type) {
case PUSH:
history.push(action.path);
history.push(`${action.path}`);
break;
case REPLACE:
history.replace(action.path);
@ -57,24 +61,13 @@ export function routeMiddleware() {
};
}
function decode(location) {
location.pathname = decodeURIComponent(location.pathname);
return location;
}
function match(routes, location) {
let params;
for (let i = 0; i < routes.length; i++) {
params = routes[i].pattern.match(location.pathname);
const params = routes[i].pattern.match(location.pathname);
if (params !== null) {
const keys = Object.keys(params);
for (let j = 0; j < keys.length; j++) {
params[keys[j]] = decodeURIComponent(params[keys[j]]);
}
return locationChanged(routes[i].name, params, decode(location));
return locationChanged(routes[i].name, params, location);
}
}
return null;
}
export default function initRouter(routes, store) {
@ -93,16 +86,11 @@ export default function initRouter(routes, store) {
let matched = match(patterns, history.location);
if (matched) {
store.dispatch(matched);
} else {
matched = { location: {} };
}
history.listen(location => {
history.listen(({ location }) => {
const nextMatch = match(patterns, location);
if (
nextMatch &&
nextMatch.location.pathname !== matched.location.pathname
) {
if (nextMatch && nextMatch.path !== matched?.path) {
matched = nextMatch;
store.dispatch(matched);
}

View File

@ -1,4 +1,5 @@
let width, height;
let width;
let height;
const listeners = [];
function update() {

6
client/jsconfig.json Normal file
View File

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

View File

@ -5,79 +5,88 @@
"license": "MIT",
"main": "index.js",
"browserslist": [
"Edge >= 16",
"Edge >= 79",
"Firefox >= 60",
"Chrome >= 61",
"Safari >= 10.1",
"iOS >= 10.3"
],
"devDependencies": {
"@babel/core": "^7.1.5",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-proposal-export-default-from": "^7.0.0",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
"@babel/plugin-transform-react-inline-elements": "^7.0.0",
"@babel/preset-env": "^7.1.5",
"@babel/preset-react": "^7.0.0",
"babel-core": "^7.0.0-0",
"babel-eslint": "^10.0.1",
"babel-jest": "^23.6.0",
"babel-loader": "^8.0.4",
"@babel/core": "^7.10.2",
"@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/plugin-proposal-export-default-from": "^7.10.1",
"@babel/plugin-proposal-export-namespace-from": "^7.10.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-react-constant-elements": "^7.10.1",
"@babel/plugin-transform-react-inline-elements": "^7.10.1",
"@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.10.1",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.0.1",
"babel-loader": "^8.1.0",
"brotli": "^1.3.1",
"css-loader": "^1.0.1",
"cssnano": "^4.1.7",
"del": "^3.0.0",
"eslint": "^5.8.0",
"eslint-config-airbnb": "^17.1.0",
"eslint-config-prettier": "^3.1.0",
"eslint-import-resolver-webpack": "^0.10.1",
"eslint-loader": "^2.1.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-react": "^7.11.1",
"express": "^4.16.4",
"express-http-proxy": "^1.5.0",
"gulp": "4.0.0",
"canvas": "^2.6.1",
"copy-webpack-plugin": "^6.0.2",
"cross-env": "^7.0.2",
"css-loader": "^3.5.3",
"cssnano": "^4.1.10",
"del": "^5.1.0",
"eslint": "^7.2.0",
"eslint-config-airbnb": "^18.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-import-resolver-webpack": "^0.12.2",
"eslint-loader": "^4.0.2",
"eslint-plugin-babel": "^5.3.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-jsx-a11y": "^6.3.0",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.0.4",
"express": "^4.17.1",
"express-http-proxy": "^1.6.0",
"gulp": "4.0.2",
"gulp-util": "^3.0.8",
"jest": "^23.6.0",
"mini-css-extract-plugin": "^0.4.4",
"postcss-flexbugs-fixes": "^4.1.0",
"jest": "^26.0.1",
"mini-css-extract-plugin": "^0.9.0",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.4.0",
"prettier": "1.15.2",
"react-test-renderer": "^16.7.0-alpha.0",
"style-loader": "^0.23.1",
"terser-webpack-plugin": "^1.1.0",
"through2": "^3.0.0",
"webpack": "^4.25.1",
"webpack-dev-middleware": "^3.4.0",
"webpack-hot-middleware": "^2.24.3",
"webpack-plugin-hash-output": "^3.1.0",
"workbox-webpack-plugin": "^3.6.3"
"postcss-preset-env": "^6.7.0",
"prettier": "2.0.5",
"react-test-renderer": "16.13.1",
"style-loader": "^1.2.1",
"terser-webpack-plugin": "^3.0.6",
"through2": "^3.0.1",
"webpack": "^4.43.0",
"webpack-dev-middleware": "^3.7.2",
"webpack-hot-middleware": "^2.25.0",
"webpack-plugin-hash-output": "^3.2.1",
"workbox-webpack-plugin": "^5.1.3"
},
"dependencies": {
"autolinker": "^1.7.1",
"@sindresorhus/fnv1a": "^2.0.1",
"autolinker": "^3.14.1",
"backo": "^1.1.0",
"classnames": "^2.2.6",
"fontfaceobserver": "^2.0.9",
"formik": "^1.3.1",
"history": "4.5.1",
"hsluv": "^0.0.3",
"immer": "^1.7.3",
"js-cookie": "^2.1.4",
"lodash": "^4.17.11",
"react": "^16.7.0-alpha.0",
"react-dom": "^16.7.0-alpha.0",
"react-hot-loader": "^4.4.0",
"react-redux": "^6.0.0-beta.2",
"formik": "^2.1.4",
"history": "^5.0.0-beta.8",
"hsluv": "^0.1.0",
"immer": "^7.0.1",
"js-cookie": "^2.2.1",
"lodash": "^4.17.15",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-hot-loader": "^4.12.21",
"react-icons": "^3.7.0",
"react-modal": "^3.11.2",
"react-redux": "^7.2.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.2.2",
"redux": "^4.0.1",
"react-window": "^1.8.5",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"url-pattern": "^1.0.3"
"url-pattern": "^1.0.3",
"workbox-core": "^5.1.3",
"workbox-precaching": "^5.1.3",
"workbox-routing": "^5.1.3"
},
"scripts": {
"prettier": "prettier --write {.*,*.js,css/*.css,**/*.test.js}",
@ -85,10 +94,9 @@
"test": "jest",
"test:verbose": "jest --verbose",
"test:watch": "jest --watch",
"gen:install": "GO111MODULE=off go get -u github.com/andyleap/gencode github.com/mailru/easyjson/... github.com/SlinSo/egon/cmd/egon",
"gen:install": "cross-env GO111MODULE=off go get -u github.com/andyleap/gencode github.com/mailru/easyjson/...",
"gen:binary": "gencode go -package storage -schema ../storage/storage.schema -unsafe",
"gen:json": "easyjson -all -lower_camel_case -omit_empty ../server/json.go ../server/index_data.go && easyjson -lower_camel_case -omit_empty ../storage/user.go",
"gen:template": "egon -s -m ../server"
"gen:json": "cross-env GO111MODULE=off easyjson -all -lower_camel_case -omit_empty ../server/json.go ../server/index_data.go ../storage/network.go && cross-env GO111MODULE=off easyjson -lower_camel_case -omit_empty ../storage/user.go"
},
"jest": {
"moduleNameMapper": {
@ -96,6 +104,9 @@
"^containers(.*)$": "<rootDir>/js/containers$1",
"^state(.*)$": "<rootDir>/js/state$1",
"^utils(.*)$": "<rootDir>/js/utils$1"
}
},
"transformIgnorePatterns": [
"node_modules/?!(history)"
]
}
}

Binary file not shown.

Binary file not shown.

View File

@ -17,5 +17,5 @@
"background_color": "#f0f0f0",
"display": "standalone",
"scope": "/",
"theme_color": "#f0f0f0"
"theme_color": "#222"
}

View File

@ -4,9 +4,12 @@ var postcssPresetEnv = require('postcss-preset-env');
module.exports = {
mode: 'development',
entry: ['webpack-hot-middleware/client', './js/index'],
entry: {
main: ['webpack-hot-middleware/client', './js/index'],
boot: './js/boot'
},
output: {
filename: 'bundle.js',
filename: '[name].js',
publicPath: '/'
},
resolve: {
@ -28,7 +31,11 @@ module.exports = {
fix: true
}
},
{ test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ },
{
test: /\.js$/,
use: ['babel-loader', 'react-hot-loader/webpack'],
exclude: /node_modules/
},
{
test: /\.css$/,
use: [

View File

@ -5,6 +5,7 @@ var cssnano = require('cssnano');
var TerserPlugin = require('terser-webpack-plugin');
var { InjectManifest } = require('workbox-webpack-plugin');
var HashOutputPlugin = require('webpack-plugin-hash-output');
var CopyPlugin = require('copy-webpack-plugin');
module.exports = {
mode: 'production',
@ -79,28 +80,27 @@ module.exports = {
filename: '[name].[contenthash].css',
chunkFilename: '[name].[contenthash].css'
}),
new HashOutputPlugin(),
new CopyPlugin({
patterns: ['public']
}),
new InjectManifest({
swSrc: './js/sw.js',
importWorkboxFrom: 'local',
globDirectory: './public',
globPatterns: ['*', 'font/*.woff2'],
exclude: [
/\.map$/,
/^manifest.*\.js(?:on)?$/,
/^boot.*\.js$/,
/^runtime.*\.js$/
]
}),
new HashOutputPlugin()
additionalManifestEntries: [
{
url: '/',
revision: '__INDEX_REVISON__'
}
],
exclude: [/\.map$/, /^boot.*\.js$/, /^runtime.*\.js$/, /\.txt$/]
})
],
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
safari10: true
},
cache: true,
parallel: true
}
})
],
splitChunks: {

File diff suppressed because it is too large Load Diff

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