Compare commits
170 Commits
v1.0.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ccba04ba | |||
| e68534a846 | |||
| 5b96dd0d8b | |||
| 4c245ad49e | |||
|
|
bdfde7f1e0 | ||
|
|
223b9b1531 | ||
|
|
8aae4a2b07 | ||
|
|
910ce284a2 | ||
|
|
1e630aed0d | ||
|
|
b39b46fa12 | ||
|
|
65a0a34fa1 | ||
|
|
642e90f51c | ||
|
|
64c2325db8 | ||
|
|
700919b5c4 | ||
|
|
e91c246a95 | ||
|
|
5b7459f24d | ||
|
|
af3a255824 | ||
|
|
7e785ed101 | ||
|
|
02cc554df6 | ||
|
|
6aef3e906b | ||
|
|
9879dbc722 | ||
|
|
afbd7c0bb3 | ||
|
|
e09541ad2f | ||
|
|
6905b9d768 | ||
|
|
caf6e9978b | ||
|
|
fbfa123dca | ||
|
|
cd45ead256 | ||
|
|
fcc80a85e3 | ||
|
|
76b6931ebb | ||
|
|
7d068fd1fe | ||
|
|
735dd8fd8c | ||
|
|
c461f4903e | ||
|
|
f897e7d11b | ||
|
|
95749ba516 | ||
|
|
39a2bc4a3d | ||
|
|
614ed5c895 | ||
|
|
8d96f93fb5 | ||
|
|
9922d11654 | ||
|
|
57c5f2b1cc | ||
|
|
0cc1c53fa4 | ||
|
|
93d7d22726 | ||
|
|
136353b2b5 | ||
|
|
7dd21177bc | ||
|
|
ca0cfdcc28 | ||
|
|
1e3903c014 | ||
|
|
5146b0cad8 | ||
|
|
513cf825a5 | ||
|
|
9fef11564d | ||
|
|
9dda4ee438 | ||
|
|
9299f79bab | ||
|
|
e4088304bf | ||
|
|
6ea3601718 | ||
|
|
bcf3741ab4 | ||
|
|
ec5e67336f | ||
|
|
4d988c98d0 | ||
|
|
62895d59ff | ||
|
|
2f1bf8a2fe | ||
|
|
75eb175e24 | ||
|
|
db0a69dcfd | ||
|
|
9c2beac7dd | ||
|
|
8ff1cd8317 | ||
|
|
f6e8f83d4e | ||
|
|
18fa0ebc6a | ||
|
|
afa09cfc25 | ||
|
|
977752e0f2 | ||
|
|
4bce52f162 | ||
|
|
75ec7cd212 | ||
|
|
24e6767cab | ||
|
|
ad165389f0 | ||
|
|
daef362931 | ||
|
|
3ba0bfe3e6 | ||
|
|
b67cd10c64 | ||
|
|
205a617c51 | ||
|
|
4145907d36 | ||
|
|
c6e63d5724 | ||
|
|
2f6efb56de | ||
|
|
cf54beacc2 | ||
|
|
69485716a0 | ||
|
|
b9d12bc8cd | ||
|
|
b93db7ac0e | ||
|
|
d96e34da79 | ||
|
|
78bfd16f25 | ||
|
|
07ae5f7167 | ||
|
|
312c755c11 | ||
|
|
a03ad28438 | ||
|
|
f389ea6ffd | ||
|
|
7c445d0bc9 | ||
|
|
97920ff7f6 | ||
|
|
b89fd604d0 | ||
|
|
6693cc0c78 | ||
|
|
6747c03a75 | ||
|
|
35e924258a | ||
|
|
26792ec386 | ||
|
|
a3b375ab3f | ||
|
|
e1a15ceeb9 | ||
|
|
9e68316467 | ||
|
|
6be24e8ed9 | ||
|
|
301f133272 | ||
|
|
9bcfd088c2 | ||
|
|
39de184734 | ||
|
|
2c0f2a80e9 | ||
|
|
1c5dc652a9 | ||
|
|
b06ebc0267 | ||
|
|
f657a81824 | ||
|
|
c69869209f | ||
|
|
331a2f0c4e | ||
|
|
6c324d44a1 | ||
|
|
d9f7faad88 | ||
|
|
f698d7a250 | ||
|
|
0f273b9699 | ||
|
|
3d03c0dbcf | ||
|
|
0b7726819d | ||
|
|
8faff95631 | ||
|
|
4d6f14ab0b | ||
|
|
9924f08794 | ||
|
|
f79b6bfaa1 | ||
|
|
269e034581 | ||
|
|
1ea7c30744 | ||
|
|
87e88cccca | ||
|
|
97b9efcc9f | ||
|
|
7ec9ae7faa | ||
|
|
ebcb731e2f | ||
|
|
23ceda5523 | ||
|
|
e843fe3ecb | ||
|
|
5171e0010d | ||
|
|
5db432b57a | ||
|
|
3584c1eb10 | ||
|
|
c1c7c91c38 | ||
|
|
2fe2ce6912 | ||
|
|
57f7b1c011 | ||
|
|
5d3738bc40 | ||
|
|
429b4595e7 | ||
|
|
038cc68ee4 | ||
|
|
15cc546876 | ||
|
|
a514104c55 | ||
|
|
7e5e94cda0 | ||
|
|
75d721c02d | ||
|
|
141fc3e07c | ||
|
|
b38777e92a | ||
|
|
0640ff8712 | ||
|
|
67b2b07506 | ||
|
|
15e451f7f8 | ||
|
|
617a3a7485 | ||
|
|
4f828db244 | ||
|
|
bc19829673 | ||
|
|
5b8c886c91 | ||
|
|
c6a1513a07 | ||
|
|
3771b39979 | ||
|
|
ca4b3575b1 | ||
|
|
68cb6c56c4 | ||
|
|
e0de4d1b36 | ||
|
|
06f7cf9565 | ||
|
|
5e33919cce | ||
|
|
97b5970acb | ||
|
|
ffbbde7f28 | ||
|
|
8f29f0c35d | ||
|
|
3b383308d4 | ||
|
|
3a95fd5ba4 | ||
|
|
44a064274d | ||
|
|
fe016807da | ||
|
|
10d988b891 | ||
|
|
a0ed50a8e2 | ||
|
|
2f627eecad | ||
|
|
2d651ef901 | ||
|
|
535bdb2f52 | ||
|
|
57f64e9cc2 | ||
|
|
57809be989 | ||
|
|
6c26ee2156 | ||
|
|
5db0105dbd | ||
|
|
c8fda8ed53 |
16
.build.yml
16
.build.yml
@@ -3,17 +3,25 @@ packages:
|
|||||||
- npm
|
- npm
|
||||||
- rsync
|
- rsync
|
||||||
sources:
|
sources:
|
||||||
- https://git.sr.ht/~emersion/gamja
|
- https://codeberg.org/emersion/gamja.git
|
||||||
secrets:
|
secrets:
|
||||||
- 77c7956b-003e-44f7-bb5c-2944b2047654 # deploy SSH key
|
- 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key
|
||||||
|
artifacts:
|
||||||
|
- gamja/gamja.tar.gz
|
||||||
tasks:
|
tasks:
|
||||||
- setup: |
|
- setup: |
|
||||||
cd gamja
|
cd gamja
|
||||||
npm install --include=dev
|
npm clean-install --include=dev
|
||||||
|
- build: |
|
||||||
|
cd gamja
|
||||||
npm run build
|
npm run build
|
||||||
|
tar -czf gamja.tar.gz -C dist .
|
||||||
|
- lint: |
|
||||||
|
cd gamja
|
||||||
|
npm run -- lint --max-warnings 0
|
||||||
- deploy: |
|
- deploy: |
|
||||||
cd gamja/dist
|
cd gamja/dist
|
||||||
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
|
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
|
||||||
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
|
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
|
||||||
--delete --exclude=config.json \
|
--delete --exclude=config.json \
|
||||||
. deploy@sheeta.emersion.fr:/srv/http/gamja
|
. deploy-gamja@sheeta.emersion.fr:/srv/http/gamja
|
||||||
|
|||||||
39
.gitea/workflows/build.yml
Normal file
39
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Bun Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout código
|
||||||
|
run: |
|
||||||
|
git clone https://fedesrv.ddns.net/git/${{ github.repository }}.git .
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Build gamja
|
||||||
|
run: bun build ./index.html --outdir ./dist --minify
|
||||||
|
|
||||||
|
- name: Generar fecha
|
||||||
|
run: echo "BUILD_DATE=$(date +'%Y-%m-%d_%H-%M')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: gamja-dist-${{ env.BUILD_DATE }}
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
121
README.md
121
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A simple IRC web client.
|
A simple IRC web client.
|
||||||
|
|
||||||

|
<img src="https://fs.emersion.fr/protected/img/gamja/main.png" alt="Screenshot" width="800">
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -10,54 +10,9 @@ Requires an IRC WebSocket server.
|
|||||||
|
|
||||||
First install dependencies:
|
First install dependencies:
|
||||||
|
|
||||||
npm install --production
|
npm install --omit=dev
|
||||||
|
|
||||||
Then configure an HTTP server to serve the gamja files. Below are some
|
Then [configure an HTTP server] to serve the gamja files.
|
||||||
server-specific instructions.
|
|
||||||
|
|
||||||
### [soju]
|
|
||||||
|
|
||||||
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
|
|
||||||
|
|
||||||
Configure your reverse proxy to serve gamja files and proxy `/socket` to soju.
|
|
||||||
|
|
||||||
### [webircgateway]
|
|
||||||
|
|
||||||
Setup webircgateway to serve gamja files:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[fileserving]
|
|
||||||
enabled = true
|
|
||||||
webroot = /path/to/gamja
|
|
||||||
```
|
|
||||||
|
|
||||||
Then connect to webircgateway and append `?server=/webirc/websocket/` to the
|
|
||||||
URL.
|
|
||||||
|
|
||||||
### nginx
|
|
||||||
|
|
||||||
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
|
|
||||||
timeout to a value higher than the IRC server PING interval. Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
location / {
|
|
||||||
root /path/to/gamja;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /socket {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If you are unable to configure the proxy timeout accordingly, or if your IRC
|
|
||||||
server doesn't send PINGs, you can set the `server.ping` option in
|
|
||||||
`config.json` (see below).
|
|
||||||
|
|
||||||
### Development server
|
### Development server
|
||||||
|
|
||||||
@@ -76,67 +31,13 @@ Optionally, [Parcel] can be used to build a minified version of gamja.
|
|||||||
npm install --include=dev
|
npm install --include=dev
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
## Query parameters
|
## Configuration
|
||||||
|
|
||||||
gamja settings can be overridden using URL query parameters:
|
gamja can be configured via a [configuration file] and via [URL parameters].
|
||||||
|
|
||||||
- `server`: path or URL to the WebSocket server
|
|
||||||
- `nick`: nickname
|
|
||||||
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
|
|
||||||
- `open`: [IRC URL] to open
|
|
||||||
- `debug`: if set to 1, debug mode is enabled
|
|
||||||
|
|
||||||
Alternatively, the channels can be set with the URL fragment (ie, by just
|
|
||||||
appending the channel name to the gamja URL).
|
|
||||||
|
|
||||||
## Configuration file
|
|
||||||
|
|
||||||
gamja default settings can be set using a `config.json` file at the root:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
// IRC server settings.
|
|
||||||
"server": {
|
|
||||||
// WebSocket URL or path to connect to (string). Defaults to "/socket".
|
|
||||||
"url": "wss://irc.example.org",
|
|
||||||
// Channel(s) to auto-join (string or array of strings).
|
|
||||||
"autojoin": "#gamja",
|
|
||||||
// Controls how the password UI is presented to the user. Set to
|
|
||||||
// "mandatory" to require a password, "optional" to accept one but not
|
|
||||||
// require it, "disabled" to never ask for a password, "external" to
|
|
||||||
// use SASL EXTERNAL, "oauth2" to use SASL OAUTHBEARER. Defaults to
|
|
||||||
// "optional".
|
|
||||||
"auth": "optional",
|
|
||||||
// Default nickname (string). If it contains a "*" character, it will
|
|
||||||
// be replaced with a random string.
|
|
||||||
"nick": "asdf",
|
|
||||||
// Don't display the login UI, immediately connect to the server
|
|
||||||
// (boolean).
|
|
||||||
"autoconnect": true,
|
|
||||||
// Interval in seconds to send PING commands (number). Set to 0 to
|
|
||||||
// disable. Enabling PINGs can have an impact on client power usage and
|
|
||||||
// should only be enabled if necessary.
|
|
||||||
"ping": 60
|
|
||||||
},
|
|
||||||
// OAuth 2.0 settings.
|
|
||||||
"oauth2": {
|
|
||||||
// OAuth 2.0 server URL (string). The server must support OAuth 2.0
|
|
||||||
// Authorization Server Metadata (RFC 8414) or OpenID Connect
|
|
||||||
// Discovery.
|
|
||||||
"url": "https://auth.example.org",
|
|
||||||
// OAuth 2.0 client ID (string).
|
|
||||||
"client_id": "asdf",
|
|
||||||
// OAuth 2.0 client secret (string).
|
|
||||||
"client_secret": "ghjk",
|
|
||||||
// OAuth 2.0 scope (string).
|
|
||||||
"scope": "profile"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
|
Send patches on [Codeberg], report bugs on the [issue tracker]. Discuss
|
||||||
in [#soju on Libera Chat].
|
in [#soju on Libera Chat].
|
||||||
|
|
||||||
## License
|
## License
|
||||||
@@ -145,11 +46,11 @@ AGPLv3, see LICENSE.
|
|||||||
|
|
||||||
Copyright (C) 2020 The gamja Contributors
|
Copyright (C) 2020 The gamja Contributors
|
||||||
|
|
||||||
[gamja]: https://sr.ht/~emersion/gamja/
|
[gamja]: https://codeberg.org/emersion/gamja
|
||||||
[soju]: https://soju.im
|
[Codeberg]: https://codeberg.org/emersion/gamja
|
||||||
[webircgateway]: https://github.com/kiwiirc/webircgateway
|
|
||||||
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
|
|
||||||
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
||||||
[Parcel]: https://parceljs.org
|
[Parcel]: https://parceljs.org
|
||||||
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
|
[configure an HTTP server]: doc/setup.md
|
||||||
|
[configuration file]: doc/config-file.md
|
||||||
|
[URL parameters]: doc/url-params.md
|
||||||
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju
|
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju
|
||||||
|
|||||||
155
commands.js
155
commands.js
@@ -25,21 +25,20 @@ function getActiveChannel(app) {
|
|||||||
return activeBuffer.name;
|
return activeBuffer.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUserHostMode(app, args, mode) {
|
async function setUserHostMode(app, args, mode) {
|
||||||
let nick = args[0];
|
let nick = args[0];
|
||||||
if (!nick) {
|
if (!nick) {
|
||||||
throw new Error("Missing nick");
|
throw new Error("Missing nick");
|
||||||
}
|
}
|
||||||
let activeChannel = getActiveChannel(app);
|
let activeChannel = getActiveChannel(app);
|
||||||
let client = getActiveClient(app);
|
let client = getActiveClient(app);
|
||||||
client.whois(nick).then((whois) => {
|
let whois = await client.whois(nick);
|
||||||
const info = whois[irc.RPL_WHOISUSER].params;
|
const info = whois[irc.RPL_WHOISUSER].params;
|
||||||
const user = info[2];
|
const user = info[2];
|
||||||
const host = info[3];
|
const host = info[3];
|
||||||
client.send({
|
client.send({
|
||||||
command: "MODE",
|
command: "MODE",
|
||||||
params: [activeChannel, mode, `*!${user}@${host}`],
|
params: [activeChannel, mode, `*!${user}@${host}`],
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +53,7 @@ function markServerBufferUnread(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const join = {
|
const join = {
|
||||||
|
name: "join",
|
||||||
usage: "<name> [password]",
|
usage: "<name> [password]",
|
||||||
description: "Join a channel",
|
description: "Join a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -70,6 +70,7 @@ const join = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const kick = {
|
const kick = {
|
||||||
|
name: "kick",
|
||||||
usage: "<nick> [comment]",
|
usage: "<nick> [comment]",
|
||||||
description: "Remove a user from the channel",
|
description: "Remove a user from the channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -84,10 +85,11 @@ const kick = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ban = {
|
const ban = {
|
||||||
|
name: "ban",
|
||||||
usage: "[nick]",
|
usage: "[nick]",
|
||||||
description: "Ban a user from the channel, or display the current ban list",
|
description: "Ban a user from the channel, or display the current ban list",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
if (args.length == 0) {
|
if (args.length === 0) {
|
||||||
let activeChannel = getActiveChannel(app);
|
let activeChannel = getActiveChannel(app);
|
||||||
getActiveClient(app).send({
|
getActiveClient(app).send({
|
||||||
command: "MODE",
|
command: "MODE",
|
||||||
@@ -112,20 +114,22 @@ function givemode(app, args, mode) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const commands = [
|
||||||
"away": {
|
{
|
||||||
|
name: "away",
|
||||||
usage: "[message]",
|
usage: "[message]",
|
||||||
description: "Set away message",
|
description: "Set away message",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
const params = []
|
const params = [];
|
||||||
if (args.length) {
|
if (args.length) {
|
||||||
params.push(args.join(" "));
|
params.push(args.join(" "));
|
||||||
}
|
}
|
||||||
getActiveClient(app).send({command: "AWAY", params});
|
getActiveClient(app).send({ command: "AWAY", params });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"ban": ban,
|
ban,
|
||||||
"buffer": {
|
{
|
||||||
|
name: "buffer",
|
||||||
usage: "<name>",
|
usage: "<name>",
|
||||||
description: "Switch to a buffer",
|
description: "Switch to a buffer",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -139,39 +143,45 @@ export default {
|
|||||||
throw new Error("Unknown buffer");
|
throw new Error("Unknown buffer");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"close": {
|
{
|
||||||
|
name: "close",
|
||||||
description: "Close the current buffer",
|
description: "Close the current buffer",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
|
let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
|
||||||
if (!activeBuffer || activeBuffer.type == BufferType.SERVER) {
|
if (!activeBuffer || activeBuffer.type === BufferType.SERVER) {
|
||||||
throw new Error("Not in a user or channel buffer");
|
throw new Error("Not in a user or channel buffer");
|
||||||
}
|
}
|
||||||
app.close(activeBuffer.id);
|
app.close(activeBuffer.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"deop": {
|
{
|
||||||
|
name: "deop",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove operator status for a user on this channel",
|
description: "Remove operator status for a user on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "-o"),
|
execute: (app, args) => givemode(app, args, "-o"),
|
||||||
},
|
},
|
||||||
"devoice": {
|
{
|
||||||
|
name: "devoice",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove voiced status for a user on this channel",
|
description: "Remove voiced status for a user on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "-v"),
|
execute: (app, args) => givemode(app, args, "-v"),
|
||||||
},
|
},
|
||||||
"disconnect": {
|
{
|
||||||
|
name: "disconnect",
|
||||||
description: "Disconnect from the server",
|
description: "Disconnect from the server",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.disconnect();
|
app.disconnect();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"help": {
|
{
|
||||||
|
name: "help",
|
||||||
description: "Show help menu",
|
description: "Show help menu",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.openHelp();
|
app.openHelp();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"invite": {
|
{
|
||||||
|
name: "invite",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Invite a user to the channel",
|
description: "Invite a user to the channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -180,15 +190,17 @@ export default {
|
|||||||
throw new Error("Missing nick");
|
throw new Error("Missing nick");
|
||||||
}
|
}
|
||||||
let activeChannel = getActiveChannel(app);
|
let activeChannel = getActiveChannel(app);
|
||||||
getActiveClient(app).send({ command: "INVITE", params: [
|
getActiveClient(app).send({
|
||||||
nick, activeChannel,
|
command: "INVITE",
|
||||||
]});
|
params: [nick, activeChannel],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"j": join,
|
{ ...join, name: "j" },
|
||||||
"join": join,
|
join,
|
||||||
"kick": kick,
|
kick,
|
||||||
"kickban": {
|
{
|
||||||
|
name: "kickban",
|
||||||
usage: "<target>",
|
usage: "<target>",
|
||||||
description: "Ban a user and removes them from the channel",
|
description: "Ban a user and removes them from the channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -196,7 +208,8 @@ export default {
|
|||||||
ban.execute(app, args);
|
ban.execute(app, args);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"lusers": {
|
{
|
||||||
|
name: "lusers",
|
||||||
usage: "[<mask> [<target>]]",
|
usage: "[<mask> [<target>]]",
|
||||||
description: "Request user statistics about the network",
|
description: "Request user statistics about the network",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -204,7 +217,8 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"me": {
|
{
|
||||||
|
name: "me",
|
||||||
usage: "<action>",
|
usage: "<action>",
|
||||||
description: "Send an action message to the current buffer",
|
description: "Send an action message to the current buffer",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -214,7 +228,8 @@ export default {
|
|||||||
app.privmsg(target, text);
|
app.privmsg(target, text);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"mode": {
|
{
|
||||||
|
name: "mode",
|
||||||
usage: "[target] [modes] [mode args...]",
|
usage: "[target] [modes] [mode args...]",
|
||||||
description: "Query or change a channel or user mode",
|
description: "Query or change a channel or user mode",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -226,7 +241,8 @@ export default {
|
|||||||
getActiveClient(app).send({ command: "MODE", params: args });
|
getActiveClient(app).send({ command: "MODE", params: args });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"motd": {
|
{
|
||||||
|
name: "motd",
|
||||||
usage: "[server]",
|
usage: "[server]",
|
||||||
description: "Get the Message Of The Day",
|
description: "Get the Message Of The Day",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -234,7 +250,8 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"msg": {
|
{
|
||||||
|
name: "msg",
|
||||||
usage: "<target> <message>",
|
usage: "<target> <message>",
|
||||||
description: "Send a message to a nickname or a channel",
|
description: "Send a message to a nickname or a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -243,7 +260,8 @@ export default {
|
|||||||
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
|
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"nick": {
|
{
|
||||||
|
name: "nick",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Change current nickname",
|
description: "Change current nickname",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -251,7 +269,8 @@ export default {
|
|||||||
getActiveClient(app).send({ command: "NICK", params: [newNick] });
|
getActiveClient(app).send({ command: "NICK", params: [newNick] });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"notice": {
|
{
|
||||||
|
name: "notice",
|
||||||
usage: "<target> <message>",
|
usage: "<target> <message>",
|
||||||
description: "Send a notice to a nickname or a channel",
|
description: "Send a notice to a nickname or a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -260,12 +279,14 @@ export default {
|
|||||||
getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
|
getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"op": {
|
{
|
||||||
|
name: "op",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Give a user operator status on this channel",
|
description: "Give a user operator status on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "+o"),
|
execute: (app, args) => givemode(app, args, "+o"),
|
||||||
},
|
},
|
||||||
"part": {
|
{
|
||||||
|
name: "part",
|
||||||
usage: "[reason]",
|
usage: "[reason]",
|
||||||
description: "Leave a channel",
|
description: "Leave a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -278,7 +299,8 @@ export default {
|
|||||||
getActiveClient(app).send({ command: "PART", params });
|
getActiveClient(app).send({ command: "PART", params });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"query": {
|
{
|
||||||
|
name: "query",
|
||||||
usage: "<nick> [message]",
|
usage: "<nick> [message]",
|
||||||
description: "Open a buffer to send messages to a nickname",
|
description: "Open a buffer to send messages to a nickname",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -294,11 +316,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"quiet": {
|
{
|
||||||
|
name: "quiet",
|
||||||
usage: "[nick]",
|
usage: "[nick]",
|
||||||
description: "Quiet a user in the channel, or display the current quiet list",
|
description: "Quiet a user in the channel, or display the current quiet list",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
if (args.length == 0) {
|
if (args.length === 0) {
|
||||||
getActiveClient(app).send({
|
getActiveClient(app).send({
|
||||||
command: "MODE",
|
command: "MODE",
|
||||||
params: [getActiveChannel(app), "+q"],
|
params: [getActiveChannel(app), "+q"],
|
||||||
@@ -308,13 +331,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"quit": {
|
{
|
||||||
|
name: "quit",
|
||||||
description: "Quit",
|
description: "Quit",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.close({ name: SERVER_BUFFER });
|
app.close({ name: SERVER_BUFFER });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"quote": {
|
{
|
||||||
|
name: "quote",
|
||||||
usage: "<command>",
|
usage: "<command>",
|
||||||
description: "Send a raw IRC command to the server",
|
description: "Send a raw IRC command to the server",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -327,13 +352,15 @@ export default {
|
|||||||
getActiveClient(app).send(msg);
|
getActiveClient(app).send(msg);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"reconnect": {
|
{
|
||||||
|
name: "reconnect",
|
||||||
description: "Reconnect to the server",
|
description: "Reconnect to the server",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.reconnect();
|
app.reconnect();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"setname": {
|
{
|
||||||
|
name: "setname",
|
||||||
usage: "<realname>",
|
usage: "<realname>",
|
||||||
description: "Change current realname",
|
description: "Change current realname",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -346,7 +373,8 @@ export default {
|
|||||||
// TODO: save to local storage
|
// TODO: save to local storage
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"stats": {
|
{
|
||||||
|
name: "stats",
|
||||||
usage: "<query> [server]",
|
usage: "<query> [server]",
|
||||||
description: "Request server statistics",
|
description: "Request server statistics",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -362,7 +390,8 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"topic": {
|
{
|
||||||
|
name: "topic",
|
||||||
usage: "<topic>",
|
usage: "<topic>",
|
||||||
description: "Change the topic of the current channel",
|
description: "Change the topic of the current channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -374,31 +403,30 @@ export default {
|
|||||||
getActiveClient(app).send({ command: "TOPIC", params });
|
getActiveClient(app).send({ command: "TOPIC", params });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"unban": {
|
{
|
||||||
|
name: "unban",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove a user from the ban list",
|
description: "Remove a user from the ban list",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
return setUserHostMode(app, args, "-b");
|
return setUserHostMode(app, args, "-b");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"unquiet": {
|
{
|
||||||
|
name: "unquiet",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove a user from the quiet list",
|
description: "Remove a user from the quiet list",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
return setUserHostMode(app, args, "-q");
|
return setUserHostMode(app, args, "-q");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"unvoice": {
|
{
|
||||||
usage: "<nick>",
|
name: "voice",
|
||||||
description: "Remove a user from the voiced list",
|
|
||||||
execute: (app, args) => givemode(app, args, "-v"),
|
|
||||||
},
|
|
||||||
"voice": {
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Give a user voiced status on this channel",
|
description: "Give a user voiced status on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "+v"),
|
execute: (app, args) => givemode(app, args, "+v"),
|
||||||
},
|
},
|
||||||
"who": {
|
{
|
||||||
|
name: "who",
|
||||||
usage: "<mask>",
|
usage: "<mask>",
|
||||||
description: "Retrieve a list of users",
|
description: "Retrieve a list of users",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -406,7 +434,8 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"whois": {
|
{
|
||||||
|
name: "whois",
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Retrieve information about a user",
|
description: "Retrieve information about a user",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -418,7 +447,8 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"whowas": {
|
{
|
||||||
|
name: "whowas",
|
||||||
usage: "<nick> [count]",
|
usage: "<nick> [count]",
|
||||||
description: "Retrieve information about an offline user",
|
description: "Retrieve information about an offline user",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -429,7 +459,8 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"list": {
|
{
|
||||||
|
name: "list",
|
||||||
usage: "[filter]",
|
usage: "[filter]",
|
||||||
description: "Retrieve a list of channels from a network",
|
description: "Retrieve a list of channels from a network",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@@ -437,4 +468,6 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
];
|
||||||
|
|
||||||
|
export default new Map(commands.map((cmd) => [cmd.name, cmd]));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import AuthForm from "./auth-form.js";
|
|||||||
import RegisterForm from "./register-form.js";
|
import RegisterForm from "./register-form.js";
|
||||||
import VerifyForm from "./verify-form.js";
|
import VerifyForm from "./verify-form.js";
|
||||||
import SettingsForm from "./settings-form.js";
|
import SettingsForm from "./settings-form.js";
|
||||||
|
import SwitcherForm from "./switcher-form.js";
|
||||||
import Composer from "./composer.js";
|
import Composer from "./composer.js";
|
||||||
import ScrollManager from "./scroll-manager.js";
|
import ScrollManager from "./scroll-manager.js";
|
||||||
import Dialog from "./dialog.js";
|
import Dialog from "./dialog.js";
|
||||||
@@ -54,7 +55,7 @@ function isProduction() {
|
|||||||
// NODE_ENV is set by the Parcel build system
|
// NODE_ENV is set by the Parcel build system
|
||||||
try {
|
try {
|
||||||
return process.env.NODE_ENV === "production";
|
return process.env.NODE_ENV === "production";
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ function splitHostPort(str) {
|
|||||||
function fillConnectParams(params) {
|
function fillConnectParams(params) {
|
||||||
let host = window.location.host || "localhost:8080";
|
let host = window.location.host || "localhost:8080";
|
||||||
let proto = "wss:";
|
let proto = "wss:";
|
||||||
if (window.location.protocol != "https:") {
|
if (window.location.protocol !== "https:") {
|
||||||
proto = "ws:";
|
proto = "ws:";
|
||||||
}
|
}
|
||||||
let path = window.location.pathname || "/";
|
let path = window.location.pathname || "/";
|
||||||
@@ -199,6 +200,7 @@ export default class App extends Component {
|
|||||||
autoOpenURL = null;
|
autoOpenURL = null;
|
||||||
messageNotifications = new Set();
|
messageNotifications = new Set();
|
||||||
baseTitle = null;
|
baseTitle = null;
|
||||||
|
lastFocusPingDate = null;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -218,6 +220,7 @@ export default class App extends Component {
|
|||||||
this.handleAddNetworkClick = this.handleAddNetworkClick.bind(this);
|
this.handleAddNetworkClick = this.handleAddNetworkClick.bind(this);
|
||||||
this.handleNetworkSubmit = this.handleNetworkSubmit.bind(this);
|
this.handleNetworkSubmit = this.handleNetworkSubmit.bind(this);
|
||||||
this.handleNetworkRemove = this.handleNetworkRemove.bind(this);
|
this.handleNetworkRemove = this.handleNetworkRemove.bind(this);
|
||||||
|
this.showError = this.showError.bind(this);
|
||||||
this.handleDismissError = this.handleDismissError.bind(this);
|
this.handleDismissError = this.handleDismissError.bind(this);
|
||||||
this.handleAuthSubmit = this.handleAuthSubmit.bind(this);
|
this.handleAuthSubmit = this.handleAuthSubmit.bind(this);
|
||||||
this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this);
|
this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this);
|
||||||
@@ -226,6 +229,8 @@ export default class App extends Component {
|
|||||||
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
|
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
|
||||||
this.handleSettingsChange = this.handleSettingsChange.bind(this);
|
this.handleSettingsChange = this.handleSettingsChange.bind(this);
|
||||||
this.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
|
this.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
|
||||||
|
this.handleSwitchSubmit = this.handleSwitchSubmit.bind(this);
|
||||||
|
this.handleWindowFocus = this.handleWindowFocus.bind(this);
|
||||||
|
|
||||||
this.state.settings = {
|
this.state.settings = {
|
||||||
...this.state.settings,
|
...this.state.settings,
|
||||||
@@ -319,6 +324,8 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
if (queryParams.debug === "1") {
|
if (queryParams.debug === "1") {
|
||||||
this.debug = true;
|
this.debug = true;
|
||||||
|
} else if (queryParams.debug === "0") {
|
||||||
|
this.debug = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.hash) {
|
if (window.location.hash) {
|
||||||
@@ -379,7 +386,7 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: false, connectParams: connectParams });
|
this.setState({ loading: false, connectParams });
|
||||||
|
|
||||||
if (connectParams.autoconnect) {
|
if (connectParams.autoconnect) {
|
||||||
this.setState({ connectForm: false });
|
this.setState({ connectForm: false });
|
||||||
@@ -433,6 +440,9 @@ export default class App extends Component {
|
|||||||
clientSecret: this.config.oauth2.client_secret,
|
clientSecret: this.config.oauth2.client_secret,
|
||||||
});
|
});
|
||||||
username = data.username;
|
username = data.username;
|
||||||
|
if (!username) {
|
||||||
|
console.warn("Username missing from OAuth 2.0 token introspection response");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to introspect OAuth 2.0 token:", err);
|
console.warn("Failed to introspect OAuth 2.0 token:", err);
|
||||||
}
|
}
|
||||||
@@ -489,15 +499,16 @@ export default class App extends Component {
|
|||||||
|
|
||||||
let stored = this.bufferStore.get({ name, server: client.params });
|
let stored = this.bufferStore.get({ name, server: client.params });
|
||||||
if (client.caps.enabled.has("draft/chathistory") && stored) {
|
if (client.caps.enabled.has("draft/chathistory") && stored) {
|
||||||
this.setBufferState({ server: serverID, name }, { unread: stored.unread });
|
this.setBufferState({ server: serverID, name }, { unread: stored.unread }, () => {
|
||||||
}
|
this.updateDocumentTitle();
|
||||||
if (!stored) {
|
|
||||||
this.bufferStore.put({
|
|
||||||
name,
|
|
||||||
server: client.params,
|
|
||||||
unread: Unread.NONE,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.bufferStore.put({
|
||||||
|
name,
|
||||||
|
server: client.params,
|
||||||
|
closed: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createBuffer(serverID, name) {
|
createBuffer(serverID, name) {
|
||||||
@@ -507,7 +518,7 @@ export default class App extends Component {
|
|||||||
this.setState((state) => {
|
this.setState((state) => {
|
||||||
let updated;
|
let updated;
|
||||||
[id, updated] = State.createBuffer(state, name, serverID, client);
|
[id, updated] = State.createBuffer(state, name, serverID, client);
|
||||||
isNew = !!updated;
|
isNew = Boolean(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -538,13 +549,9 @@ export default class App extends Component {
|
|||||||
let client = this.clients.get(buf.server);
|
let client = this.clients.get(buf.server);
|
||||||
let stored = this.bufferStore.get({ name: buf.name, server: client.params });
|
let stored = this.bufferStore.get({ name: buf.name, server: client.params });
|
||||||
let prevReadReceipt = getReceipt(stored, ReceiptType.READ);
|
let prevReadReceipt = getReceipt(stored, ReceiptType.READ);
|
||||||
// TODO: only mark as read if user scrolled at the bottom
|
let update = State.updateBuffer(state, buf.id, { prevReadReceipt });
|
||||||
let update = State.updateBuffer(state, buf.id, {
|
|
||||||
unread: Unread.NONE,
|
|
||||||
prevReadReceipt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ...update, activeBuffer: buf.id };
|
return { activeBuffer: buf.id, ...update };
|
||||||
}, () => {
|
}, () => {
|
||||||
if (!buf) {
|
if (!buf) {
|
||||||
return;
|
return;
|
||||||
@@ -554,6 +561,35 @@ export default class App extends Component {
|
|||||||
this.buffer.current.focus();
|
this.buffer.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let server = this.state.servers.get(buf.server);
|
||||||
|
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
|
||||||
|
this.whoUserBuffer(buf.name, buf.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
|
||||||
|
this.whoChannelBuffer(buf.name, buf.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateDocumentTitle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: only mark as read if user scrolled at the bottom
|
||||||
|
this.markBufferAsRead(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
markBufferAsRead(id) {
|
||||||
|
let buf;
|
||||||
|
this.setState((state) => {
|
||||||
|
buf = State.getBuffer(state, id);
|
||||||
|
if (!buf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return State.updateBuffer(state, buf.id, { unread: Unread.NONE });
|
||||||
|
}, () => {
|
||||||
|
if (!buf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let client = this.clients.get(buf.server);
|
let client = this.clients.get(buf.server);
|
||||||
|
|
||||||
for (let notif of this.messageNotifications) {
|
for (let notif of this.messageNotifications) {
|
||||||
@@ -575,23 +611,46 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let server = this.state.servers.get(buf.server);
|
this.updateDocumentTitle();
|
||||||
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
|
|
||||||
this.whoUserBuffer(buf.name, buf.server);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
|
|
||||||
this.whoChannelBuffer(buf.name, buf.server);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buf.type !== BufferType.SERVER) {
|
|
||||||
document.title = buf.name + ' · ' + this.baseTitle;
|
|
||||||
} else {
|
|
||||||
document.title = this.baseTitle;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateDocumentTitle() {
|
||||||
|
let buf = State.getBuffer(this.state, this.state.activeBuffer);
|
||||||
|
let server;
|
||||||
|
if (buf) {
|
||||||
|
server = this.state.servers.get(buf.server);
|
||||||
|
}
|
||||||
|
let bouncerNetwork;
|
||||||
|
if (server.bouncerNetID) {
|
||||||
|
bouncerNetwork = this.state.bouncerNetworks.get(server.bouncerNetID);
|
||||||
|
}
|
||||||
|
|
||||||
|
let numUnread = 0;
|
||||||
|
for (let buffer of this.state.buffers.values()) {
|
||||||
|
if (Unread.compare(buffer.unread, Unread.HIGHLIGHT) >= 0) {
|
||||||
|
numUnread++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = [];
|
||||||
|
if (buf && buf.type !== BufferType.SERVER) {
|
||||||
|
parts.push(buf.name);
|
||||||
|
}
|
||||||
|
if (bouncerNetwork) {
|
||||||
|
parts.push(getServerName(server, bouncerNetwork));
|
||||||
|
}
|
||||||
|
parts.push(this.baseTitle);
|
||||||
|
|
||||||
|
let title = "";
|
||||||
|
if (numUnread > 0) {
|
||||||
|
title = `(${numUnread}) `;
|
||||||
|
}
|
||||||
|
title += parts.join(" · ");
|
||||||
|
|
||||||
|
document.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
prepareChatMessage(serverID, msg) {
|
prepareChatMessage(serverID, msg) {
|
||||||
// Treat server-wide broadcasts as highlights. They're sent by server
|
// Treat server-wide broadcasts as highlights. They're sent by server
|
||||||
// operators and can contain important information.
|
// operators and can contain important information.
|
||||||
@@ -609,12 +668,6 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addChatMessage(serverID, bufName, msg) {
|
|
||||||
this.prepareChatMessage(serverID, msg);
|
|
||||||
let bufID = { server: serverID, name: bufName };
|
|
||||||
this.setState((state) => State.addMessage(state, msg, bufID));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChatMessage(serverID, bufName, msg) {
|
handleChatMessage(serverID, bufName, msg) {
|
||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
|
|
||||||
@@ -631,7 +684,7 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let msgUnread = Unread.NONE;
|
let msgUnread = Unread.NONE;
|
||||||
if ((msg.command == "PRIVMSG" || msg.command == "NOTICE") && !isRead) {
|
if ((msg.command === "PRIVMSG" || msg.command === "NOTICE") && !isRead) {
|
||||||
let target = msg.params[0];
|
let target = msg.params[0];
|
||||||
let text = msg.params[1];
|
let text = msg.params[1];
|
||||||
|
|
||||||
@@ -646,7 +699,7 @@ export default class App extends Component {
|
|||||||
msgUnread = Unread.MESSAGE;
|
msgUnread = Unread.MESSAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msgUnread == Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) {
|
if (msgUnread === Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) {
|
||||||
let title = "New " + kind + " from " + msg.prefix.name;
|
let title = "New " + kind + " from " + msg.prefix.name;
|
||||||
if (client.isChannel(bufName)) {
|
if (client.isChannel(bufName)) {
|
||||||
title += " in " + bufName;
|
title += " in " + bufName;
|
||||||
@@ -704,7 +757,7 @@ export default class App extends Component {
|
|||||||
|
|
||||||
// Open a new buffer if the message doesn't come from me or is a
|
// Open a new buffer if the message doesn't come from me or is a
|
||||||
// self-message
|
// self-message
|
||||||
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command != "PART" && msg.comand != "QUIT")) {
|
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command !== "PART" && msg.command !== "QUIT" && msg.command !== irc.RPL_MONONLINE && msg.command !== irc.RPL_MONOFFLINE)) {
|
||||||
this.createBuffer(serverID, bufName);
|
this.createBuffer(serverID, bufName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,7 +769,7 @@ export default class App extends Component {
|
|||||||
let prevReadReceipt = buf.prevReadReceipt;
|
let prevReadReceipt = buf.prevReadReceipt;
|
||||||
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
|
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
|
||||||
|
|
||||||
if (this.state.activeBuffer !== buf.id) {
|
if (this.state.activeBuffer !== buf.id || !document.hasFocus()) {
|
||||||
unread = Unread.union(unread, msgUnread);
|
unread = Unread.union(unread, msgUnread);
|
||||||
} else {
|
} else {
|
||||||
receipts[ReceiptType.READ] = receiptFromMessage(msg);
|
receipts[ReceiptType.READ] = receiptFromMessage(msg);
|
||||||
@@ -737,6 +790,10 @@ export default class App extends Component {
|
|||||||
this.sendReadReceipt(client, stored);
|
this.sendReadReceipt(client, stored);
|
||||||
}
|
}
|
||||||
return { unread, prevReadReceipt };
|
return { unread, prevReadReceipt };
|
||||||
|
}, () => {
|
||||||
|
if (msgUnread === Unread.HIGHLIGHT) {
|
||||||
|
this.updateDocumentTitle();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,6 +903,12 @@ export default class App extends Component {
|
|||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
let chatHistoryBatch = irc.findBatchByType(msg, "chathistory");
|
let chatHistoryBatch = irc.findBatchByType(msg, "chathistory");
|
||||||
|
|
||||||
|
// Reply triggered by some command sent by us, not worth displaying to
|
||||||
|
// the user
|
||||||
|
if (msg.internal) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
let target, channel, affectedBuffers;
|
let target, channel, affectedBuffers;
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case "MODE":
|
case "MODE":
|
||||||
@@ -861,7 +924,7 @@ export default class App extends Component {
|
|||||||
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
|
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
|
||||||
target = SERVER_BUFFER;
|
target = SERVER_BUFFER;
|
||||||
} else {
|
} else {
|
||||||
let context = msg.tags['+draft/channel-context'];
|
let context = msg.tags["+draft/channel-context"];
|
||||||
if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) {
|
if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) {
|
||||||
target = context;
|
target = context;
|
||||||
} else {
|
} else {
|
||||||
@@ -870,6 +933,14 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let allowedPrefixes = client.isupport.statusMsg();
|
||||||
|
if (allowedPrefixes) {
|
||||||
|
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
||||||
|
if (client.isChannel(parts.name)) {
|
||||||
|
target = parts.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't open a new buffer if this is just a NOTICE or a garbage
|
// Don't open a new buffer if this is just a NOTICE or a garbage
|
||||||
// CTCP message
|
// CTCP message
|
||||||
let openNewBuffer = true;
|
let openNewBuffer = true;
|
||||||
@@ -885,13 +956,6 @@ export default class App extends Component {
|
|||||||
target = SERVER_BUFFER;
|
target = SERVER_BUFFER;
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowedPrefixes = client.isupport.statusMsg();
|
|
||||||
if (allowedPrefixes) {
|
|
||||||
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
|
||||||
if (client.isChannel(parts.name)) {
|
|
||||||
target = parts.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [target];
|
return [target];
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
@@ -911,10 +975,10 @@ export default class App extends Component {
|
|||||||
affectedBuffers.push(chatHistoryBatch.params[0]);
|
affectedBuffers.push(chatHistoryBatch.params[0]);
|
||||||
} else {
|
} else {
|
||||||
this.state.buffers.forEach((buf) => {
|
this.state.buffers.forEach((buf) => {
|
||||||
if (buf.server != serverID) {
|
if (buf.server !== serverID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!buf.members.has(msg.prefix.name) && client.cm(buf.name) !== client.cm(msg.prefix.name)) {
|
if (!buf.members.has(msg.prefix.name)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
affectedBuffers.push(buf.name);
|
affectedBuffers.push(buf.name);
|
||||||
@@ -929,7 +993,7 @@ export default class App extends Component {
|
|||||||
affectedBuffers.push(chatHistoryBatch.params[0]);
|
affectedBuffers.push(chatHistoryBatch.params[0]);
|
||||||
} else {
|
} else {
|
||||||
this.state.buffers.forEach((buf) => {
|
this.state.buffers.forEach((buf) => {
|
||||||
if (buf.server != serverID) {
|
if (buf.server !== serverID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!buf.members.has(msg.prefix.name)) {
|
if (!buf.members.has(msg.prefix.name)) {
|
||||||
@@ -970,6 +1034,15 @@ export default class App extends Component {
|
|||||||
case irc.RPL_INVITING:
|
case irc.RPL_INVITING:
|
||||||
channel = msg.params[2];
|
channel = msg.params[2];
|
||||||
return [channel];
|
return [channel];
|
||||||
|
case irc.RPL_MONONLINE:
|
||||||
|
case irc.RPL_MONOFFLINE:
|
||||||
|
let targets = msg.params[1].split(",");
|
||||||
|
affectedBuffers = [];
|
||||||
|
for (let target of targets) {
|
||||||
|
let prefix = irc.parsePrefix(target);
|
||||||
|
affectedBuffers.push(prefix.name);
|
||||||
|
}
|
||||||
|
return affectedBuffers;
|
||||||
case irc.RPL_YOURHOST:
|
case irc.RPL_YOURHOST:
|
||||||
case irc.RPL_MYINFO:
|
case irc.RPL_MYINFO:
|
||||||
case irc.RPL_ISUPPORT:
|
case irc.RPL_ISUPPORT:
|
||||||
@@ -981,8 +1054,6 @@ export default class App extends Component {
|
|||||||
case irc.RPL_TOPICWHOTIME:
|
case irc.RPL_TOPICWHOTIME:
|
||||||
case irc.RPL_NAMREPLY:
|
case irc.RPL_NAMREPLY:
|
||||||
case irc.RPL_ENDOFNAMES:
|
case irc.RPL_ENDOFNAMES:
|
||||||
case irc.RPL_MONONLINE:
|
|
||||||
case irc.RPL_MONOFFLINE:
|
|
||||||
case irc.RPL_SASLSUCCESS:
|
case irc.RPL_SASLSUCCESS:
|
||||||
case irc.RPL_CHANNEL_URL:
|
case irc.RPL_CHANNEL_URL:
|
||||||
case "AWAY":
|
case "AWAY":
|
||||||
@@ -999,6 +1070,7 @@ export default class App extends Component {
|
|||||||
case "ACK":
|
case "ACK":
|
||||||
case "BOUNCER":
|
case "BOUNCER":
|
||||||
case "MARKREAD":
|
case "MARKREAD":
|
||||||
|
case "REDACT":
|
||||||
// Ignore these
|
// Ignore these
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
@@ -1029,7 +1101,7 @@ export default class App extends Component {
|
|||||||
// Restore opened channel and user buffers
|
// Restore opened channel and user buffers
|
||||||
let join = [];
|
let join = [];
|
||||||
for (let buf of this.bufferStore.list(client.params)) {
|
for (let buf of this.bufferStore.list(client.params)) {
|
||||||
if (buf.name === "*") {
|
if (buf.name === "*" || buf.closed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,13 +1140,14 @@ export default class App extends Component {
|
|||||||
this.openURL(this.autoOpenURL);
|
this.openURL(this.autoOpenURL);
|
||||||
this.autoOpenURL = null;
|
this.autoOpenURL = null;
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
|
|
||||||
if (client.isMyNick(msg.prefix.name)) {
|
if (client.isMyNick(msg.prefix.name)) {
|
||||||
this.syncBufferUnread(serverID, channel);
|
this.syncBufferUnread(serverID, channel);
|
||||||
}
|
}
|
||||||
if (channel == this.switchToChannel) {
|
if (channel === this.switchToChannel) {
|
||||||
this.switchBuffer({ server: serverID, name: channel });
|
this.switchBuffer({ server: serverID, name: channel });
|
||||||
this.switchToChannel = null;
|
this.switchToChannel = null;
|
||||||
}
|
}
|
||||||
@@ -1167,9 +1240,13 @@ export default class App extends Component {
|
|||||||
notif.close();
|
notif.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let unread;
|
||||||
|
let closed = true;
|
||||||
this.setBufferState({ server: serverID, name: target }, (buf) => {
|
this.setBufferState({ server: serverID, name: target }, (buf) => {
|
||||||
|
closed = false;
|
||||||
|
|
||||||
// Re-compute unread status
|
// Re-compute unread status
|
||||||
let unread = Unread.NONE;
|
unread = Unread.NONE;
|
||||||
for (let i = buf.messages.length - 1; i >= 0; i--) {
|
for (let i = buf.messages.length - 1; i >= 0; i--) {
|
||||||
let msg = buf.messages[i];
|
let msg = buf.messages[i];
|
||||||
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
||||||
@@ -1187,18 +1264,20 @@ export default class App extends Component {
|
|||||||
unread = Unread.MESSAGE;
|
unread = Unread.MESSAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { unread };
|
||||||
|
}, () => {
|
||||||
this.bufferStore.put({
|
this.bufferStore.put({
|
||||||
name: target,
|
name: target,
|
||||||
server: client.params,
|
server: client.params,
|
||||||
unread,
|
unread,
|
||||||
|
closed,
|
||||||
receipts: { [ReceiptType.READ]: readReceipt },
|
receipts: { [ReceiptType.READ]: readReceipt },
|
||||||
});
|
});
|
||||||
|
this.updateDocumentTitle();
|
||||||
return { unread };
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
if (irc.isError(msg.command) && msg.command !== irc.ERR_NOMOTD) {
|
||||||
let description = msg.params[msg.params.length - 1];
|
let description = msg.params[msg.params.length - 1];
|
||||||
this.showError(description);
|
this.showError(description);
|
||||||
}
|
}
|
||||||
@@ -1209,7 +1288,7 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchBacklog(serverID) {
|
async fetchBacklog(serverID) {
|
||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
if (!client.caps.enabled.has("draft/chathistory")) {
|
if (!client.caps.enabled.has("draft/chathistory")) {
|
||||||
return;
|
return;
|
||||||
@@ -1224,45 +1303,48 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = irc.formatDate(new Date());
|
let now = irc.formatDate(new Date());
|
||||||
client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => {
|
let targets = await client.fetchHistoryTargets(now, lastReceipt.time);
|
||||||
targets.forEach((target) => {
|
targets.forEach(async (target) => {
|
||||||
let from = lastReceipt;
|
let from = lastReceipt;
|
||||||
let to = { time: now };
|
let to = { time: now };
|
||||||
|
|
||||||
// Maybe we've just received a READ update from the
|
// Maybe we've just received a READ update from the
|
||||||
// server, avoid over-fetching history
|
// server, avoid over-fetching history
|
||||||
let stored = this.bufferStore.get({ name: target.name, server: client.params });
|
let stored = this.bufferStore.get({ name: target.name, server: client.params });
|
||||||
let readReceipt = getReceipt(stored, ReceiptType.READ);
|
let readReceipt = getReceipt(stored, ReceiptType.READ);
|
||||||
if (isReceiptBefore(from, readReceipt)) {
|
if (isReceiptBefore(from, readReceipt)) {
|
||||||
from = readReceipt;
|
from = readReceipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already have messages stored for the target,
|
||||||
|
// fetch all messages we've missed
|
||||||
|
let buf = State.getBuffer(this.state, { server: serverID, name: target.name });
|
||||||
|
if (buf && buf.messages.length > 0) {
|
||||||
|
let lastMsg = buf.messages[buf.messages.length - 1];
|
||||||
|
from = receiptFromMessage(lastMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query read marker if this is a user (ie, we haven't received
|
||||||
|
// the read marker as part of a JOIN burst)
|
||||||
|
if (client.supportsReadMarker() && client.isNick(target.name)) {
|
||||||
|
client.fetchReadMarker(target.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
|
||||||
|
this.showError("Failed to fetch backlog for '" + target.name + "'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let msg of result.messages) {
|
||||||
|
let destBuffers = this.routeMessage(serverID, msg);
|
||||||
|
for (let bufName of destBuffers) {
|
||||||
|
this.handleChatMessage(serverID, bufName, msg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// If we already have messages stored for the target,
|
|
||||||
// fetch all messages we've missed
|
|
||||||
let buf = State.getBuffer(this.state, { server: serverID, name: target.name });
|
|
||||||
if (buf && buf.messages.length > 0) {
|
|
||||||
let lastMsg = buf.messages[buf.messages.length - 1];
|
|
||||||
from = receiptFromMessage(lastMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query read marker if this is a user (ie, we haven't received
|
|
||||||
// the read marker as part of a JOIN burst)
|
|
||||||
if (client.supportsReadMarker() && client.isNick(target.name)) {
|
|
||||||
client.fetchReadMarker(target.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => {
|
|
||||||
for (let msg of result.messages) {
|
|
||||||
let destBuffers = this.routeMessage(serverID, msg);
|
|
||||||
for (let bufName of destBuffers) {
|
|
||||||
this.handleChatMessage(serverID, bufName, msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
|
|
||||||
this.showError("Failed to fetch backlog for '" + target.name + "'");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1367,14 +1449,21 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
whoChannelBuffer(target, serverID) {
|
async whoChannelBuffer(target, serverID) {
|
||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
|
|
||||||
client.who(target, {
|
// Prevent multiple WHO commands for the same channel in parallel
|
||||||
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
|
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
|
||||||
}).then(() => {
|
|
||||||
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
|
let hasInitialWho = false;
|
||||||
});
|
try {
|
||||||
|
await client.who(target, {
|
||||||
|
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
|
||||||
|
});
|
||||||
|
hasInitialWho = true;
|
||||||
|
} finally {
|
||||||
|
this.setBufferState({ name: target, server: serverID }, { hasInitialWho });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open(target, serverID, password) {
|
open(target, serverID, password) {
|
||||||
@@ -1436,7 +1525,7 @@ export default class App extends Component {
|
|||||||
servers.delete(buf.server);
|
servers.delete(buf.server);
|
||||||
|
|
||||||
let connectForm = state.connectForm;
|
let connectForm = state.connectForm;
|
||||||
if (servers.size == 0) {
|
if (servers.size === 0) {
|
||||||
connectForm = true;
|
connectForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1474,7 +1563,11 @@ export default class App extends Component {
|
|||||||
|
|
||||||
client.unmonitor(buf.name);
|
client.unmonitor(buf.name);
|
||||||
|
|
||||||
this.bufferStore.delete({ name: buf.name, server: client.params });
|
this.bufferStore.put({
|
||||||
|
name: buf.name,
|
||||||
|
server: client.params,
|
||||||
|
closed: true,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1488,7 +1581,7 @@ export default class App extends Component {
|
|||||||
let name = parts[0].toLowerCase().slice(1);
|
let name = parts[0].toLowerCase().slice(1);
|
||||||
let args = parts.slice(1);
|
let args = parts.slice(1);
|
||||||
|
|
||||||
let cmd = commands[name];
|
let cmd = commands.get(name);
|
||||||
if (!cmd) {
|
if (!cmd) {
|
||||||
this.showError(`Unknown command "${name}" (run "/help" to get a command list)`);
|
this.showError(`Unknown command "${name}" (run "/help" to get a command list)`);
|
||||||
return;
|
return;
|
||||||
@@ -1503,7 +1596,7 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
privmsg(target, text) {
|
privmsg(target, text) {
|
||||||
if (target == SERVER_BUFFER) {
|
if (target === SERVER_BUFFER) {
|
||||||
this.showError("Cannot send message in server buffer");
|
this.showError("Cannot send message in server buffer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1620,8 +1713,8 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prefix.startsWith("/")) {
|
if (prefix.startsWith("/")) {
|
||||||
let repl = fromList(Object.keys(commands), prefix.slice(1));
|
let repl = fromList([...commands.keys()], prefix.slice(1));
|
||||||
return repl.map(cmd => "/" + cmd);
|
return repl.map((cmd) => "/" + cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: consider using the CHANTYPES ISUPPORT token here
|
// TODO: consider using the CHANTYPES ISUPPORT token here
|
||||||
@@ -1646,9 +1739,9 @@ export default class App extends Component {
|
|||||||
this.openDialog("help");
|
this.openDialog("help");
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBufferScrollTop() {
|
async handleBufferScrollTop() {
|
||||||
let buf = this.state.buffers.get(this.state.activeBuffer);
|
let buf = this.state.buffers.get(this.state.activeBuffer);
|
||||||
if (!buf || buf.type == BufferType.SERVER) {
|
if (!buf || buf.type === BufferType.SERVER) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1676,35 +1769,39 @@ export default class App extends Component {
|
|||||||
limit = 200;
|
limit = 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
|
let result = await client.fetchHistoryBefore(buf.name, before, limit);
|
||||||
this.endOfHistory.set(buf.id, !result.more);
|
this.endOfHistory.set(buf.id, !result.more);
|
||||||
|
|
||||||
if (result.messages.length > 0) {
|
if (result.messages.length > 0) {
|
||||||
let msg = result.messages[result.messages.length - 1];
|
let msg = result.messages[result.messages.length - 1];
|
||||||
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
|
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
|
||||||
if (this.state.activeBuffer === buf.id) {
|
if (this.state.activeBuffer === buf.id) {
|
||||||
receipts[ReceiptType.READ] = receiptFromMessage(msg);
|
receipts[ReceiptType.READ] = receiptFromMessage(msg);
|
||||||
}
|
|
||||||
let stored = {
|
|
||||||
name: buf.name,
|
|
||||||
server: client.params,
|
|
||||||
receipts,
|
|
||||||
};
|
|
||||||
if (this.bufferStore.put(stored)) {
|
|
||||||
this.sendReadReceipt(client, stored);
|
|
||||||
}
|
|
||||||
this.setBufferState(buf, ({ prevReadReceipt }) => {
|
|
||||||
if (!isMessageBeforeReceipt(msg, prevReadReceipt)) {
|
|
||||||
prevReadReceipt = receiptFromMessage(msg);
|
|
||||||
}
|
|
||||||
return { prevReadReceipt };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
let stored = {
|
||||||
|
name: buf.name,
|
||||||
|
server: client.params,
|
||||||
|
receipts,
|
||||||
|
};
|
||||||
|
if (this.bufferStore.put(stored)) {
|
||||||
|
this.sendReadReceipt(client, stored);
|
||||||
|
}
|
||||||
|
this.setBufferState(buf, ({ prevReadReceipt }) => {
|
||||||
|
if (!isMessageBeforeReceipt(msg, prevReadReceipt)) {
|
||||||
|
prevReadReceipt = receiptFromMessage(msg);
|
||||||
|
}
|
||||||
|
return { prevReadReceipt };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (let msg of result.messages) {
|
for (let msg of result.messages) {
|
||||||
this.addChatMessage(buf.server, buf.name, msg);
|
this.prepareChatMessage(buf.server, msg);
|
||||||
|
let destBuffers = this.routeMessage(buf.server, msg);
|
||||||
|
for (let bufName of destBuffers) {
|
||||||
|
let bufID = { server: buf.server, name: bufName };
|
||||||
|
this.setState((state) => State.addMessage(state, msg, bufID));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openDialog(name, data) {
|
openDialog(name, data) {
|
||||||
@@ -1822,12 +1919,13 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNetworkSubmit(attrs, autojoin) {
|
async handleNetworkSubmit(attrs, autojoin) {
|
||||||
let client = this.clients.values().next().value;
|
let client = this.clients.values().next().value;
|
||||||
|
|
||||||
|
this.dismissDialog();
|
||||||
|
|
||||||
if (this.state.dialogData && this.state.dialogData.id) {
|
if (this.state.dialogData && this.state.dialogData.id) {
|
||||||
if (Object.keys(attrs).length == 0) {
|
if (Object.keys(attrs).length === 0) {
|
||||||
this.dismissDialog();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1837,22 +1935,19 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
attrs = { ...attrs, tls: "1" };
|
attrs = { ...attrs, tls: "1" };
|
||||||
client.createBouncerNetwork(attrs).then((id) => {
|
let id = await client.createBouncerNetwork(attrs);
|
||||||
if (!autojoin) {
|
if (!autojoin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// By this point, bouncer-networks-notify should've advertised
|
// By this point, bouncer-networks-notify should've advertised
|
||||||
// the new network
|
// the new network
|
||||||
let serverID = this.serverFromBouncerNetwork(id);
|
let serverID = this.serverFromBouncerNetwork(id);
|
||||||
let client = this.clients.get(serverID);
|
let newClient = this.clients.get(serverID);
|
||||||
client.params.autojoin = [autojoin];
|
newClient.params.autojoin = [autojoin];
|
||||||
|
|
||||||
this.switchToChannel = autojoin;
|
this.switchToChannel = autojoin;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dismissDialog();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNetworkRemove() {
|
handleNetworkRemove() {
|
||||||
@@ -1868,7 +1963,7 @@ export default class App extends Component {
|
|||||||
|
|
||||||
handleOpenSettingsClick() {
|
handleOpenSettingsClick() {
|
||||||
let showProtocolHandler = false;
|
let showProtocolHandler = false;
|
||||||
for (let [id, client] of this.clients) {
|
for (let [_id, client] of this.clients) {
|
||||||
if (client.caps.enabled.has("soju.im/bouncer-networks")) {
|
if (client.caps.enabled.has("soju.im/bouncer-networks")) {
|
||||||
showProtocolHandler = true;
|
showProtocolHandler = true;
|
||||||
break;
|
break;
|
||||||
@@ -1888,13 +1983,44 @@ export default class App extends Component {
|
|||||||
this.disconnectAll();
|
this.disconnectAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSwitchSubmit(buf) {
|
||||||
|
this.dismissDialog();
|
||||||
|
if (buf) {
|
||||||
|
this.switchBuffer(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWindowFocus() {
|
||||||
|
if (this.state.activeBuffer) {
|
||||||
|
// TODO: only do this if scrolled at the bottom
|
||||||
|
this.markBufferAsRead(this.state.activeBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the user focuses gamja, send a PING to make sure we detect any
|
||||||
|
// network errors ASAP
|
||||||
|
|
||||||
|
let now = new Date();
|
||||||
|
if (this.lastFocusPingDate && now.getTime() - this.lastFocusPingDate.getTime() < 15 * 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastFocusPingDate = now;
|
||||||
|
|
||||||
|
for (let client of this.clients.values()) {
|
||||||
|
if (client.status === Client.Status.REGISTERED) {
|
||||||
|
client.send({ command: "PING", params: ["gamja"] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.baseTitle = document.title;
|
this.baseTitle = document.title;
|
||||||
setupKeybindings(this);
|
setupKeybindings(this);
|
||||||
|
window.addEventListener("focus", this.handleWindowFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.title = this.baseTitle;
|
document.title = this.baseTitle;
|
||||||
|
window.removeEventListener("focus", this.handleWindowFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -1917,6 +2043,11 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeClient = null;
|
||||||
|
if (activeBuffer) {
|
||||||
|
activeClient = this.clients.get(activeBuffer.server);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.state.connectForm) {
|
if (this.state.connectForm) {
|
||||||
let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
|
let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
|
||||||
let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
|
let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
|
||||||
@@ -1936,12 +2067,12 @@ export default class App extends Component {
|
|||||||
let bufferHeader = null;
|
let bufferHeader = null;
|
||||||
if (activeBuffer) {
|
if (activeBuffer) {
|
||||||
let activeUser = null;
|
let activeUser = null;
|
||||||
if (activeBuffer.type == BufferType.NICK) {
|
if (activeBuffer.type === BufferType.NICK) {
|
||||||
activeUser = activeServer.users.get(activeBuffer.name);
|
activeUser = activeServer.users.get(activeBuffer.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
bufferHeader = html`
|
bufferHeader = html`
|
||||||
<section id="buffer-header">
|
<section id="buffer-header" role="banner">
|
||||||
<${BufferHeader}
|
<${BufferHeader}
|
||||||
buffer=${activeBuffer}
|
buffer=${activeBuffer}
|
||||||
server=${activeServer}
|
server=${activeServer}
|
||||||
@@ -1960,11 +2091,13 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let memberList = null;
|
let memberList = null;
|
||||||
if (activeBuffer && activeBuffer.type == BufferType.CHANNEL) {
|
if (activeBuffer && activeBuffer.type === BufferType.CHANNEL) {
|
||||||
memberList = html`
|
memberList = html`
|
||||||
<section
|
<section
|
||||||
id="member-list"
|
id="member-list"
|
||||||
class=${this.state.openPanels.memberList ? "expand" : ""}
|
class=${this.state.openPanels.memberList ? "expand" : ""}
|
||||||
|
role="complementary"
|
||||||
|
aria-label="Members list"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="expander"
|
class="expander"
|
||||||
@@ -2075,12 +2208,23 @@ export default class App extends Component {
|
|||||||
</>
|
</>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
|
case "switch":
|
||||||
|
dialog = html`
|
||||||
|
<${Dialog} title="Switch to a channel or user" onDismiss=${this.dismissDialog}>
|
||||||
|
<${SwitcherForm}
|
||||||
|
buffers=${this.state.buffers}
|
||||||
|
servers=${this.state.servers}
|
||||||
|
bouncerNetworks=${this.state.bouncerNetworks}
|
||||||
|
onSubmit=${this.handleSwitchSubmit}/>
|
||||||
|
</>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let error = null;
|
let error = null;
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
error = html`
|
error = html`
|
||||||
<div id="error-msg">
|
<div id="error-msg" role="alert">
|
||||||
${this.state.error}
|
${this.state.error}
|
||||||
${" "}
|
${" "}
|
||||||
<button onClick=${this.handleDismissError}>×</button>
|
<button onClick=${this.handleDismissError}>×</button>
|
||||||
@@ -2104,8 +2248,8 @@ export default class App extends Component {
|
|||||||
|
|
||||||
let app = html`
|
let app = html`
|
||||||
<section
|
<section
|
||||||
id="buffer-list"
|
id="buffer-list"
|
||||||
class=${this.state.openPanels.bufferList ? "expand" : ""}
|
class=${this.state.openPanels.bufferList ? "expand" : ""}
|
||||||
>
|
>
|
||||||
<${BufferList}
|
<${BufferList}
|
||||||
buffers=${this.state.buffers}
|
buffers=${this.state.buffers}
|
||||||
@@ -2130,11 +2274,10 @@ export default class App extends Component {
|
|||||||
scrollKey=${this.state.activeBuffer}
|
scrollKey=${this.state.activeBuffer}
|
||||||
onScrollTop=${this.handleBufferScrollTop}
|
onScrollTop=${this.handleBufferScrollTop}
|
||||||
>
|
>
|
||||||
<section id="buffer" ref=${this.buffer} tabindex="-1">
|
<section id="buffer" ref=${this.buffer} tabindex="-1" role="log">
|
||||||
<${Buffer}
|
<${Buffer}
|
||||||
buffer=${activeBuffer}
|
buffer=${activeBuffer}
|
||||||
server=${activeServer}
|
server=${activeServer}
|
||||||
bouncerNetwork=${activeBouncerNetwork}
|
|
||||||
settings=${this.state.settings}
|
settings=${this.state.settings}
|
||||||
onChannelClick=${this.handleChannelClick}
|
onChannelClick=${this.handleChannelClick}
|
||||||
onNickClick=${this.handleNickClick}
|
onNickClick=${this.handleNickClick}
|
||||||
@@ -2147,8 +2290,10 @@ export default class App extends Component {
|
|||||||
${memberList}
|
${memberList}
|
||||||
<${Composer}
|
<${Composer}
|
||||||
ref=${this.composer}
|
ref=${this.composer}
|
||||||
|
client=${activeClient}
|
||||||
readOnly=${composerReadOnly}
|
readOnly=${composerReadOnly}
|
||||||
onSubmit=${this.handleComposerSubmit}
|
onSubmit=${this.handleComposerSubmit}
|
||||||
|
onError=${this.showError}
|
||||||
autocomplete=${this.autocomplete}
|
autocomplete=${this.autocomplete}
|
||||||
commandOnly=${commandOnly}
|
commandOnly=${commandOnly}
|
||||||
maxLen=${privmsgMaxLen}
|
maxLen=${privmsgMaxLen}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type === "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
import { html } from "../lib/index.js";
|
||||||
import linkify from "../lib/linkify.js";
|
import linkify from "../lib/linkify.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import { BufferType, ServerStatus, getServerName } from "../state.js";
|
import { BufferType, ServerStatus, getServerName } from "../state.js";
|
||||||
@@ -214,7 +214,7 @@ export default function BufferHeader(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = props.buffer.name;
|
let name = props.buffer.name;
|
||||||
if (props.buffer.type == BufferType.SERVER) {
|
if (props.buffer.type === BufferType.SERVER) {
|
||||||
name = getServerName(props.server, props.bouncerNetwork);
|
name = getServerName(props.server, props.bouncerNetwork);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import { html, Component } from "../lib/index.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
|
import { html } from "../lib/index.js";
|
||||||
import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
|
import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
|
||||||
|
|
||||||
function BufferItem(props) {
|
function BufferItem(props) {
|
||||||
@@ -15,18 +16,20 @@ function BufferItem(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = props.buffer.name;
|
let name = props.buffer.name;
|
||||||
if (props.buffer.type == BufferType.SERVER) {
|
if (props.buffer.type === BufferType.SERVER) {
|
||||||
name = getServerName(props.server, props.bouncerNetwork);
|
name = getServerName(props.server, props.bouncerNetwork);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let title;
|
||||||
let classes = ["type-" + props.buffer.type];
|
let classes = ["type-" + props.buffer.type];
|
||||||
if (props.active) {
|
if (props.active) {
|
||||||
classes.push("active");
|
classes.push("active");
|
||||||
}
|
}
|
||||||
if (props.buffer.unread != Unread.NONE) {
|
if (props.buffer.unread !== Unread.NONE) {
|
||||||
classes.push("unread-" + props.buffer.unread);
|
classes.push("unread-" + props.buffer.unread);
|
||||||
}
|
}
|
||||||
if (props.buffer.type === BufferType.SERVER) {
|
switch (props.buffer.type) {
|
||||||
|
case BufferType.SERVER:
|
||||||
let isError = props.server.status === ServerStatus.DISCONNECTED;
|
let isError = props.server.status === ServerStatus.DISCONNECTED;
|
||||||
if (props.bouncerNetwork && props.bouncerNetwork.error) {
|
if (props.bouncerNetwork && props.bouncerNetwork.error) {
|
||||||
isError = true;
|
isError = true;
|
||||||
@@ -34,12 +37,20 @@ function BufferItem(props) {
|
|||||||
if (isError) {
|
if (isError) {
|
||||||
classes.push("error");
|
classes.push("error");
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
case BufferType.NICK:
|
||||||
|
let user = props.server.users.get(name);
|
||||||
|
if (user && irc.isMeaningfulRealname(user.realname, name)) {
|
||||||
|
title = stripANSI(user.realname);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li class="${classes.join(" ")}">
|
<li class="${classes.join(" ")}" role="tab" aria-selected="${props.active}">
|
||||||
<a
|
<a
|
||||||
href=${getBufferURL(props.buffer)}
|
href=${getBufferURL(props.buffer)}
|
||||||
|
title=${title}
|
||||||
onClick=${handleClick}
|
onClick=${handleClick}
|
||||||
onMouseDown=${handleMouseDown}
|
onMouseDown=${handleMouseDown}
|
||||||
>${name}</a>
|
>${name}</a>
|
||||||
@@ -47,7 +58,6 @@ function BufferItem(props) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function BufferList(props) {
|
export default function BufferList(props) {
|
||||||
let items = Array.from(props.buffers.values()).map((buf) => {
|
let items = Array.from(props.buffers.values()).map((buf) => {
|
||||||
let server = props.servers.get(buf.server);
|
let server = props.servers.get(buf.server);
|
||||||
@@ -65,10 +75,14 @@ export default function BufferList(props) {
|
|||||||
bouncerNetwork=${bouncerNetwork}
|
bouncerNetwork=${bouncerNetwork}
|
||||||
onClick=${() => props.onBufferClick(buf)}
|
onClick=${() => props.onBufferClick(buf)}
|
||||||
onClose=${() => props.onBufferClose(buf)}
|
onClose=${() => props.onBufferClose(buf)}
|
||||||
active=${props.activeBuffer == buf.id}
|
active=${props.activeBuffer === buf.id}
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return html`<ul>${items}</ul>`;
|
return html`
|
||||||
|
<ul role="tablist" aria-label="Buffer list">
|
||||||
|
${items}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { html, Component } from "../lib/index.js";
|
|||||||
import linkify from "../lib/linkify.js";
|
import linkify from "../lib/linkify.js";
|
||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import { BufferType, ServerStatus, BufferEventsDisplayMode, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
|
import { BufferType, ServerStatus, BufferEventsDisplayMode, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
|
||||||
import * as store from "../store.js";
|
import * as store from "../store.js";
|
||||||
import Membership from "./membership.js";
|
import Membership from "./membership.js";
|
||||||
|
|
||||||
@@ -21,9 +21,19 @@ function Nick(props) {
|
|||||||
props.onClick();
|
props.onClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let title;
|
||||||
|
if (props.user && irc.isMeaningfulRealname(props.user.realname, props.nick)) {
|
||||||
|
title = stripANSI(props.user.realname);
|
||||||
|
}
|
||||||
|
|
||||||
let colorIndex = djb2(props.nick) % 16 + 1;
|
let colorIndex = djb2(props.nick) % 16 + 1;
|
||||||
return html`
|
return html`
|
||||||
<a href=${getNickURL(props.nick)} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
|
<a
|
||||||
|
href=${irc.formatURL({ entity: props.nick })}
|
||||||
|
title=${title}
|
||||||
|
class="nick nick-${colorIndex}"
|
||||||
|
onClick=${handleClick}
|
||||||
|
>${props.nick}</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +43,7 @@ function _Timestamp({ date, url, showSeconds }) {
|
|||||||
if (showSeconds) {
|
if (showSeconds) {
|
||||||
timestamp += ":--";
|
timestamp += ":--";
|
||||||
}
|
}
|
||||||
return html`<spam class="timestamp">${timestamp}</span>`;
|
return html`<span class="timestamp" aria-hidden="true">${timestamp}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hh = date.getHours().toString().padStart(2, "0");
|
let hh = date.getHours().toString().padStart(2, "0");
|
||||||
@@ -84,7 +94,7 @@ function canFoldMessage(msg) {
|
|||||||
|
|
||||||
class LogLine extends Component {
|
class LogLine extends Component {
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
return this.props.message !== nextProps.message;
|
return this.props.message !== nextProps.message || this.props.redacted !== nextProps.redacted;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -98,12 +108,16 @@ class LogLine extends Component {
|
|||||||
|
|
||||||
function createNick(nick) {
|
function createNick(nick) {
|
||||||
return html`
|
return html`
|
||||||
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
|
<${Nick}
|
||||||
|
nick=${nick}
|
||||||
|
user=${server.users.get(nick)}
|
||||||
|
onClick=${() => onNickClick(nick)}
|
||||||
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
function createChannel(channel) {
|
function createChannel(channel) {
|
||||||
return html`
|
return html`
|
||||||
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
|
<a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}>
|
||||||
${channel}
|
${channel}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
@@ -120,7 +134,7 @@ class LogLine extends Component {
|
|||||||
|
|
||||||
let ctcp = irc.parseCTCP(msg);
|
let ctcp = irc.parseCTCP(msg);
|
||||||
if (ctcp) {
|
if (ctcp) {
|
||||||
if (ctcp.command == "ACTION") {
|
if (ctcp.command === "ACTION") {
|
||||||
lineClass = "me-tell";
|
lineClass = "me-tell";
|
||||||
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
|
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
|
||||||
} else {
|
} else {
|
||||||
@@ -129,15 +143,26 @@ class LogLine extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lineClass = "talk";
|
|
||||||
let prefix = "<", suffix = ">";
|
let prefix = "<", suffix = ">";
|
||||||
if (msg.command == "NOTICE") {
|
if (msg.command === "NOTICE") {
|
||||||
|
lineClass += " notice";
|
||||||
prefix = suffix = "-";
|
prefix = suffix = "-";
|
||||||
}
|
}
|
||||||
content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`;
|
if (this.props.redacted) {
|
||||||
|
content = html`<i>This message has been deleted.</i>`;
|
||||||
|
} else {
|
||||||
|
content = html`${linkify(stripANSI(text), onChannelClick)}`;
|
||||||
|
lineClass += " talk";
|
||||||
|
}
|
||||||
|
content = html`
|
||||||
|
<span class="nick-caret" aria-hidden="true">${prefix}</span>
|
||||||
|
${createNick(msg.prefix.name)}
|
||||||
|
<span class="nick-caret" aria-hidden="true">${suffix}</span>
|
||||||
|
${" "}
|
||||||
|
${content}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = null;
|
|
||||||
let allowedPrefixes = server.statusMsg;
|
let allowedPrefixes = server.statusMsg;
|
||||||
if (target !== buf.name && allowedPrefixes) {
|
if (target !== buf.name && allowedPrefixes) {
|
||||||
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
||||||
@@ -182,19 +207,94 @@ class LogLine extends Component {
|
|||||||
break;
|
break;
|
||||||
case "MODE":
|
case "MODE":
|
||||||
target = msg.params[0];
|
target = msg.params[0];
|
||||||
|
let modeStr = msg.params[1];
|
||||||
|
|
||||||
|
let user = html`${createNick(msg.prefix.name)}`;
|
||||||
|
|
||||||
|
// TODO: use irc.forEachChannelModeUpdate()
|
||||||
|
if (buf.type === BufferType.CHANNEL && modeStr.length === 2 && server.cm(buf.name) === server.cm(target)) {
|
||||||
|
let plusMinus = modeStr[0];
|
||||||
|
let mode = modeStr[1];
|
||||||
|
let arg = msg.params[2];
|
||||||
|
|
||||||
|
let verb;
|
||||||
|
switch (mode) {
|
||||||
|
case "b":
|
||||||
|
verb = plusMinus === "+" ? "added" : "removed";
|
||||||
|
content = html`${user} has ${verb} a ban on ${arg}`;
|
||||||
|
break;
|
||||||
|
case "e":
|
||||||
|
verb = plusMinus === "+" ? "added" : "removed";
|
||||||
|
content = html`${user} has ${verb} a ban exemption on ${arg}`;
|
||||||
|
break;
|
||||||
|
case "l":
|
||||||
|
if (plusMinus === "+") {
|
||||||
|
content = html`${user} has set the channel user limit to ${arg}`;
|
||||||
|
} else {
|
||||||
|
content = html`${user} has unset the channel user limit`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "i":
|
||||||
|
verb = plusMinus === "+" ? "marked": "unmarked";
|
||||||
|
content = html`${user} has ${verb} as invite-only`;
|
||||||
|
break;
|
||||||
|
case "m":
|
||||||
|
verb = plusMinus === "+" ? "marked": "unmarked";
|
||||||
|
content = html`${user} has ${verb} as moderated`;
|
||||||
|
break;
|
||||||
|
case "s":
|
||||||
|
verb = plusMinus === "+" ? "marked": "unmarked";
|
||||||
|
content = html`${user} has ${verb} as secret`;
|
||||||
|
break;
|
||||||
|
case "t":
|
||||||
|
verb = plusMinus === "+" ? "locked": "unlocked";
|
||||||
|
content = html`${user} has ${verb} the channel topic`;
|
||||||
|
break;
|
||||||
|
case "n":
|
||||||
|
verb = plusMinus === "+" ? "allowed": "denied";
|
||||||
|
content = html`${user} has ${verb} external messages to this channel`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (content) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel membership modes
|
||||||
|
let membership;
|
||||||
|
for (let prefix in irc.STD_MEMBERSHIP_MODES) {
|
||||||
|
if (irc.STD_MEMBERSHIP_MODES[prefix] === mode) {
|
||||||
|
membership = irc.STD_MEMBERSHIP_NAMES[prefix];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (membership && arg) {
|
||||||
|
let verb = plusMinus === "+" ? "granted" : "revoked";
|
||||||
|
let preposition = plusMinus === "+" ? "to" : "from";
|
||||||
|
content = html`
|
||||||
|
${user} has ${verb} ${membership} privileges ${preposition} ${createNick(arg)}
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
content = html`
|
content = html`
|
||||||
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
|
${user} sets mode ${msg.params.slice(1).join(" ")}
|
||||||
`;
|
`;
|
||||||
// TODO: case-mapping
|
if (server.cm(buf.name) !== server.cm(target)) {
|
||||||
if (buf.name !== target) {
|
|
||||||
content = html`${content} on ${target}`;
|
content = html`${content} on ${target}`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
let topic = msg.params[1];
|
let topic = msg.params[1];
|
||||||
content = html`
|
if (topic) {
|
||||||
${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
|
content = html`
|
||||||
`;
|
${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
content = html`
|
||||||
|
${createNick(msg.prefix.name)} cleared the topic
|
||||||
|
`;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "INVITE":
|
case "INVITE":
|
||||||
invitee = msg.params[0];
|
invitee = msg.params[0];
|
||||||
@@ -267,8 +367,15 @@ class LogLine extends Component {
|
|||||||
let date = new Date(parseInt(msg.params[2], 10) * 1000);
|
let date = new Date(parseInt(msg.params[2], 10) * 1000);
|
||||||
content = html`Channel was created on ${date.toLocaleString()}`;
|
content = html`Channel was created on ${date.toLocaleString()}`;
|
||||||
break;
|
break;
|
||||||
|
// MONITOR messages are only displayed in user buffers
|
||||||
|
case irc.RPL_MONONLINE:
|
||||||
|
content = html`${createNick(buf.name)} is online`;
|
||||||
|
break;
|
||||||
|
case irc.RPL_MONOFFLINE:
|
||||||
|
content = html`${createNick(buf.name)} is offline`;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
if (irc.isError(msg.command) && msg.command !== irc.ERR_NOMOTD) {
|
||||||
lineClass = "error";
|
lineClass = "error";
|
||||||
}
|
}
|
||||||
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
|
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
|
||||||
@@ -279,7 +386,7 @@ class LogLine extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="logline ${lineClass}" data-key=${msg.key}>
|
<div class="logline ${lineClass}" data-key=${msg.key} role="listitem">
|
||||||
<${Timestamp} date=${new Date(msg.tags.time)} url=${getMessageURL(buf, msg)}/>
|
<${Timestamp} date=${new Date(msg.tags.time)} url=${getMessageURL(buf, msg)}/>
|
||||||
${" "}
|
${" "}
|
||||||
${content}
|
${content}
|
||||||
@@ -318,11 +425,16 @@ class FoldGroup extends Component {
|
|||||||
render() {
|
render() {
|
||||||
let msgs = this.props.messages;
|
let msgs = this.props.messages;
|
||||||
let buf = this.props.buffer;
|
let buf = this.props.buffer;
|
||||||
|
let server = this.props.server;
|
||||||
|
|
||||||
let onNickClick = this.props.onNickClick;
|
let onNickClick = this.props.onNickClick;
|
||||||
function createNick(nick) {
|
function createNick(nick) {
|
||||||
return html`
|
return html`
|
||||||
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
|
<${Nick}
|
||||||
|
nick=${nick}
|
||||||
|
user=${server.users.get(nick)}
|
||||||
|
onClick=${() => onNickClick(nick)}
|
||||||
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +511,7 @@ class FoldGroup extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="logline" data-key=${msgs[0].key}>
|
<div class="logline" data-key=${msgs[0].key} role="listitem">
|
||||||
${timestamp}
|
${timestamp}
|
||||||
${" "}
|
${" "}
|
||||||
${content}
|
${content}
|
||||||
@@ -452,7 +564,7 @@ class NotificationNagger extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="logline">
|
<div class="logline nag" role="listitem">
|
||||||
<${Timestamp}/>
|
<${Timestamp}/>
|
||||||
${" "}
|
${" "}
|
||||||
<a href="#" onClick=${this.handleClick}>Turn on desktop notifications</a> to get notified about new messages
|
<a href="#" onClick=${this.handleClick}>Turn on desktop notifications</a> to get notified about new messages
|
||||||
@@ -493,7 +605,7 @@ class ProtocolHandlerNagger extends Component {
|
|||||||
}
|
}
|
||||||
let name = this.props.bouncerName || "this bouncer";
|
let name = this.props.bouncerName || "this bouncer";
|
||||||
return html`
|
return html`
|
||||||
<div class="logline">
|
<div class="logline nag" role="listitem">
|
||||||
<${Timestamp}/>
|
<${Timestamp}/>
|
||||||
${" "}
|
${" "}
|
||||||
<a href="#" onClick=${this.handleClick}>Register our protocol handler</a> to open IRC links with ${name}
|
<a href="#" onClick=${this.handleClick}>Register our protocol handler</a> to open IRC links with ${name}
|
||||||
@@ -531,7 +643,7 @@ function AccountNagger({ server, onAuthClick, onRegisterClick }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="logline">
|
<div class="logline nag" role="listitem">
|
||||||
<${Timestamp}/> ${msg}
|
<${Timestamp}/> ${msg}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -550,7 +662,7 @@ class DateSeparator extends Component {
|
|||||||
let date = this.props.date;
|
let date = this.props.date;
|
||||||
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
|
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
return html`
|
return html`
|
||||||
<div class="separator date-separator">
|
<div class="separator date-separator" role="separator">
|
||||||
${text}
|
${text}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -558,7 +670,7 @@ class DateSeparator extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UnreadSeparator(props) {
|
function UnreadSeparator(props) {
|
||||||
return html`<div class="separator unread-separator">New messages</div>`;
|
return html`<div class="separator unread-separator" role="separator">New messages</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameDate(d1, d2) {
|
function sameDate(d1, d2) {
|
||||||
@@ -578,18 +690,17 @@ export default class Buffer extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let server = this.props.server;
|
let server = this.props.server;
|
||||||
let bouncerNetwork = this.props.bouncerNetwork;
|
|
||||||
let settings = this.props.settings;
|
let settings = this.props.settings;
|
||||||
let serverName = server.name;
|
let serverName = server.name;
|
||||||
|
|
||||||
let children = [];
|
let children = [];
|
||||||
if (buf.type == BufferType.SERVER) {
|
if (buf.type === BufferType.SERVER) {
|
||||||
children.push(html`<${NotificationNagger}/>`);
|
children.push(html`<${NotificationNagger}/>`);
|
||||||
}
|
}
|
||||||
if (buf.type == BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
|
if (buf.type === BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
|
||||||
children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`);
|
children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`);
|
||||||
}
|
}
|
||||||
if (buf.type == BufferType.SERVER && server.status == ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) {
|
if (buf.type === BufferType.SERVER && server.status === ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) {
|
||||||
children.push(html`
|
children.push(html`
|
||||||
<${AccountNagger}
|
<${AccountNagger}
|
||||||
server=${server}
|
server=${server}
|
||||||
@@ -610,6 +721,7 @@ export default class Buffer extends Component {
|
|||||||
message=${msg}
|
message=${msg}
|
||||||
buffer=${buf}
|
buffer=${buf}
|
||||||
server=${server}
|
server=${server}
|
||||||
|
redacted=${buf.redacted.has(msg.tags.msgid)}
|
||||||
onChannelClick=${onChannelClick}
|
onChannelClick=${onChannelClick}
|
||||||
onNickClick=${onNickClick}
|
onNickClick=${onNickClick}
|
||||||
onVerifyClick=${onVerifyClick}
|
onVerifyClick=${onVerifyClick}
|
||||||
@@ -617,7 +729,38 @@ export default class Buffer extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
function createFoldGroup(msgs) {
|
function createFoldGroup(msgs) {
|
||||||
// Filter out PART → JOIN pairs
|
// Merge NICK change chains
|
||||||
|
let nickChanges = new Map();
|
||||||
|
let mergedMsgs = [];
|
||||||
|
for (let msg of msgs) {
|
||||||
|
let keep = true;
|
||||||
|
switch (msg.command) {
|
||||||
|
case "PART":
|
||||||
|
case "QUIT":
|
||||||
|
nickChanges.delete(msg.prefix.name);
|
||||||
|
break;
|
||||||
|
case "NICK":
|
||||||
|
let prev = nickChanges.get(msg.prefix.name);
|
||||||
|
if (!prev) {
|
||||||
|
// Future NICK messages may mutate this one
|
||||||
|
msg = { ...msg };
|
||||||
|
nickChanges.set(msg.params[0], msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
prev.params = msg.params;
|
||||||
|
nickChanges.delete(msg.prefix.name);
|
||||||
|
nickChanges.set(msg.params[0], prev);
|
||||||
|
keep = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (keep) {
|
||||||
|
mergedMsgs.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgs = mergedMsgs;
|
||||||
|
|
||||||
|
// Filter out PART → JOIN pairs, as well as no-op NICKs from previous step
|
||||||
let partIndexes = new Map();
|
let partIndexes = new Map();
|
||||||
let keep = [];
|
let keep = [];
|
||||||
msgs.forEach((msg, i) => {
|
msgs.forEach((msg, i) => {
|
||||||
@@ -628,6 +771,8 @@ export default class Buffer extends Component {
|
|||||||
keep[partIndexes.get(msg.prefix.name)] = false;
|
keep[partIndexes.get(msg.prefix.name)] = false;
|
||||||
partIndexes.delete(msg.prefix.name);
|
partIndexes.delete(msg.prefix.name);
|
||||||
keep.push(false);
|
keep.push(false);
|
||||||
|
} else if (msg.command === "NICK" && msg.prefix.name === msg.params[0]) {
|
||||||
|
keep.push(false);
|
||||||
} else {
|
} else {
|
||||||
keep.push(true);
|
keep.push(true);
|
||||||
}
|
}
|
||||||
@@ -653,6 +798,7 @@ export default class Buffer extends Component {
|
|||||||
let hasUnreadSeparator = false;
|
let hasUnreadSeparator = false;
|
||||||
let prevDate = new Date();
|
let prevDate = new Date();
|
||||||
let foldMessages = [];
|
let foldMessages = [];
|
||||||
|
let lastMonitor = null;
|
||||||
buf.messages.forEach((msg) => {
|
buf.messages.forEach((msg) => {
|
||||||
let sep = [];
|
let sep = [];
|
||||||
|
|
||||||
@@ -660,7 +806,15 @@ export default class Buffer extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
|
if (msg.command === irc.RPL_MONONLINE || msg.command === irc.RPL_MONOFFLINE) {
|
||||||
|
let skip = !lastMonitor || msg.command === lastMonitor;
|
||||||
|
lastMonitor = msg.command;
|
||||||
|
if (skip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUnreadSeparator && buf.type !== BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
|
||||||
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
||||||
hasUnreadSeparator = true;
|
hasUnreadSeparator = true;
|
||||||
}
|
}
|
||||||
@@ -673,7 +827,7 @@ export default class Buffer extends Component {
|
|||||||
|
|
||||||
if (sep.length > 0) {
|
if (sep.length > 0) {
|
||||||
children.push(createFoldGroup(foldMessages));
|
children.push(createFoldGroup(foldMessages));
|
||||||
children.push(sep);
|
children.push(...sep);
|
||||||
foldMessages = [];
|
foldMessages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,7 +847,7 @@ export default class Buffer extends Component {
|
|||||||
children.push(createFoldGroup(foldMessages));
|
children.push(createFoldGroup(foldMessages));
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="logline-list">
|
<div class="logline-list" role="list">
|
||||||
${children}
|
${children}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { html, Component, createRef } from "../lib/index.js";
|
import { html, Component, createRef } from "../lib/index.js";
|
||||||
|
|
||||||
|
function encodeContentDisposition(filename) {
|
||||||
|
// Encode filename according to RFC 5987 if necessary. Note,
|
||||||
|
// encodeURIComponent will percent-encode a superset of attr-char.
|
||||||
|
let encodedFilename = encodeURIComponent(filename);
|
||||||
|
if (encodedFilename === filename) {
|
||||||
|
return "attachment; filename=\"" + filename + "\"";
|
||||||
|
} else {
|
||||||
|
return "attachment; filename*=UTF-8''" + encodedFilename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class Composer extends Component {
|
export default class Composer extends Component {
|
||||||
state = {
|
state = {
|
||||||
text: "",
|
text: "",
|
||||||
@@ -13,6 +24,9 @@ export default class Composer extends Component {
|
|||||||
this.handleInput = this.handleInput.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
||||||
|
this.handleInputPaste = this.handleInputPaste.bind(this);
|
||||||
|
this.handleDragOver = this.handleDragOver.bind(this);
|
||||||
|
this.handleDrop = this.handleDrop.bind(this);
|
||||||
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
|
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
|
||||||
this.handleWindowPaste = this.handleWindowPaste.bind(this);
|
this.handleWindowPaste = this.handleWindowPaste.bind(this);
|
||||||
}
|
}
|
||||||
@@ -116,6 +130,114 @@ export default class Composer extends Component {
|
|||||||
this.setState({ text: autocomplete.text });
|
this.setState({ text: autocomplete.text });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canUploadFiles() {
|
||||||
|
let client = this.props.client;
|
||||||
|
return client && client.isupport.filehost() && !this.props.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(file) {
|
||||||
|
let client = this.props.client;
|
||||||
|
let endpoint = client.isupport.filehost();
|
||||||
|
|
||||||
|
let auth;
|
||||||
|
if (client.params.saslPlain) {
|
||||||
|
let params = client.params.saslPlain;
|
||||||
|
auth = "Basic " + btoa(params.username + ":" + params.password);
|
||||||
|
} else if (client.params.saslOauthBearer) {
|
||||||
|
auth = "Bearer " + client.params.saslOauthBearer.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers = {
|
||||||
|
"Content-Length": file.size,
|
||||||
|
"Content-Disposition": encodeContentDisposition(file.name),
|
||||||
|
};
|
||||||
|
if (file.type) {
|
||||||
|
headers["Content-Type"] = file.type;
|
||||||
|
}
|
||||||
|
if (auth) {
|
||||||
|
headers["Authorization"] = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: show a loading UI while uploading
|
||||||
|
// TODO: show a cancel button
|
||||||
|
let resp = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
headers,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`HTTP request failed (${resp.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let loc = resp.headers.get("Location");
|
||||||
|
if (!loc) {
|
||||||
|
throw new Error("filehost response missing Location header field");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(loc, endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFileList(fileList) {
|
||||||
|
let promises = [];
|
||||||
|
for (let file of fileList) {
|
||||||
|
promises.push(this.uploadFile(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls;
|
||||||
|
try {
|
||||||
|
urls = await Promise.all(promises);
|
||||||
|
} catch (err) {
|
||||||
|
this.props.onError(new Error("Failed to upload files", { cause: err }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState((state) => {
|
||||||
|
if (state.text) {
|
||||||
|
return { text: state.text + " " + urls.join(" ") };
|
||||||
|
} else {
|
||||||
|
return { text: urls.join(" ") };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInputPaste(event) {
|
||||||
|
if (event.clipboardData.files.length === 0 || !this.canUploadFiles()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
await this.uploadFileList(event.clipboardData.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragOver(event) {
|
||||||
|
if (event.dataTransfer.items.length === 0 || !this.canUploadFiles()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let item of event.dataTransfer.items) {
|
||||||
|
if (item.kind !== "file") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDrop(event) {
|
||||||
|
if (event.dataTransfer.files.length === 0 || !this.canUploadFiles()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
await this.uploadFileList(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
handleWindowKeyDown(event) {
|
handleWindowKeyDown(event) {
|
||||||
// If an <input> or <button> is focused, ignore.
|
// If an <input> or <button> is focused, ignore.
|
||||||
if (document.activeElement && document.activeElement !== document.body) {
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
@@ -173,6 +295,11 @@ export default class Composer extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.clipboardData.files.length > 0) {
|
||||||
|
this.handleInputPaste(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let text = event.clipboardData.getData("text");
|
let text = event.clipboardData.getData("text");
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -228,6 +355,9 @@ export default class Composer extends Component {
|
|||||||
placeholder=${placeholder}
|
placeholder=${placeholder}
|
||||||
enterkeyhint="send"
|
enterkeyhint="send"
|
||||||
onKeyDown=${this.handleInputKeyDown}
|
onKeyDown=${this.handleInputKeyDown}
|
||||||
|
onPaste=${this.handleInputPaste}
|
||||||
|
onDragOver=${this.handleDragOver}
|
||||||
|
onDrop=${this.handleDrop}
|
||||||
maxlength=${this.props.maxLen}
|
maxlength=${this.props.maxLen}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default class ConnectForm extends Component {
|
|||||||
|
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type === "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ export default class Dialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleBackdropClick(event) {
|
handleBackdropClick(event) {
|
||||||
if (event.target.className == "dialog") {
|
if (event.target.className === "dialog") {
|
||||||
this.dismiss();
|
this.dismiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown(event) {
|
handleKeyDown(event) {
|
||||||
if (event.key == "Escape") {
|
if (event.key === "Escape") {
|
||||||
this.dismiss();
|
this.dismiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,11 +47,11 @@ export default class Dialog extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<div class="dialog" onClick=${this.handleBackdropClick}>
|
<div class="dialog" onClick=${this.handleBackdropClick} role="dialog" aria-modal="true">
|
||||||
<div class="dialog-body" ref=${this.body}>
|
<div class="dialog-body" ref=${this.body}>
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<h2>${this.props.title}</h2>
|
<h2>${this.props.title}</h2>
|
||||||
<button class="dialog-close" onClick=${this.handleCloseClick}>×</button>
|
<button class="dialog-close" onClick=${this.handleCloseClick} title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
${this.props.children}
|
${this.props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
import { html } from "../lib/index.js";
|
||||||
import { keybindings } from "../keybindings.js";
|
import { keybindings } from "../keybindings.js";
|
||||||
import commands from "../commands.js";
|
import commands from "../commands.js";
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ function KeyBindingsHelp() {
|
|||||||
let l = keybindings.map((binding) => {
|
let l = keybindings.map((binding) => {
|
||||||
let keys = [];
|
let keys = [];
|
||||||
if (binding.ctrlKey) {
|
if (binding.ctrlKey) {
|
||||||
keys.psuh("Ctrl");
|
keys.push("Ctrl");
|
||||||
}
|
}
|
||||||
if (binding.altKey) {
|
if (binding.altKey) {
|
||||||
keys.push("Alt");
|
keys.push("Alt");
|
||||||
@@ -26,6 +26,11 @@ function KeyBindingsHelp() {
|
|||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
l.push(html`
|
||||||
|
<dt><kbd>Tab</kbd></dt>
|
||||||
|
<dd>Automatically complete nickname or channel</dd>
|
||||||
|
`);
|
||||||
|
|
||||||
if (!window.matchMedia("(pointer: none)").matches) {
|
if (!window.matchMedia("(pointer: none)").matches) {
|
||||||
l.push(html`
|
l.push(html`
|
||||||
<dt><strong>Middle mouse click</strong></dt>
|
<dt><strong>Middle mouse click</strong></dt>
|
||||||
@@ -37,8 +42,8 @@ function KeyBindingsHelp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CommandsHelp() {
|
function CommandsHelp() {
|
||||||
let l = Object.keys(commands).map((name) => {
|
let l = [...commands.keys()].map((name) => {
|
||||||
let cmd = commands[name];
|
let cmd = commands.get(name);
|
||||||
|
|
||||||
let usage = [html`<strong>/${name}</strong>`];
|
let usage = [html`<strong>/${name}</strong>`];
|
||||||
if (cmd.usage) {
|
if (cmd.usage) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default class JoinForm extends Component {
|
|||||||
|
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type === "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
import { html, Component } from "../lib/index.js";
|
||||||
import { getNickURL } from "../state.js";
|
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import Membership from "./membership.js";
|
import Membership from "./membership.js";
|
||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
@@ -23,26 +22,7 @@ class MemberItem extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// XXX: If we were feeling creative we could generate unique colors for
|
let title;
|
||||||
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
|
|
||||||
const membmap = {
|
|
||||||
"~": "owner",
|
|
||||||
"&": "admin",
|
|
||||||
"@": "op",
|
|
||||||
"%": "halfop",
|
|
||||||
"+": "voice",
|
|
||||||
};
|
|
||||||
const membclass = membmap[this.props.membership[0]] || "";
|
|
||||||
let membership = "";
|
|
||||||
if (this.props.membership) {
|
|
||||||
membership = html`
|
|
||||||
<span class="membership ${membclass}" title=${membclass}>
|
|
||||||
${this.props.membership}
|
|
||||||
</span>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
let title = null;
|
|
||||||
let user = this.props.user;
|
let user = this.props.user;
|
||||||
let classes = ["nick"];
|
let classes = ["nick"];
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -73,7 +53,7 @@ class MemberItem extends Component {
|
|||||||
return html`
|
return html`
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href=${getNickURL(this.props.nick)}
|
href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })}
|
||||||
class=${classes.join(" ")}
|
class=${classes.join(" ")}
|
||||||
title=${title}
|
title=${title}
|
||||||
onClick=${this.handleClick}
|
onClick=${this.handleClick}
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
import { html } from "../lib/index.js";
|
||||||
|
import * as irc from "../lib/irc.js";
|
||||||
// XXX: If we were feeling creative we could generate unique colors for
|
|
||||||
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
|
|
||||||
const names = {
|
|
||||||
"~": "owner",
|
|
||||||
"&": "admin",
|
|
||||||
"@": "op",
|
|
||||||
"%": "halfop",
|
|
||||||
"+": "voice",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Membership(props) {
|
export default function Membership(props) {
|
||||||
if (!this.props.value) {
|
if (!this.props.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = names[this.props.value[0]] || "";
|
// XXX: If we were feeling creative we could generate unique colors for
|
||||||
|
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
|
||||||
|
const name = irc.STD_MEMBERSHIP_NAMES[this.props.value[0]] || "";
|
||||||
return html`
|
return html`
|
||||||
<span class="membership ${name}" title=${name}>
|
<span class="membership ${name}" title=${name}>
|
||||||
${this.props.value}
|
${this.props.value}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type === "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +46,10 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
let params = {};
|
let params = {};
|
||||||
Object.keys(defaultParams).forEach((k) => {
|
Object.keys(defaultParams).forEach((k) => {
|
||||||
if (!this.props.isNew && this.prevParams[k] == this.state[k]) {
|
if (!this.props.isNew && this.prevParams[k] === this.state[k]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.props.isNew && defaultParams[k] == this.state[k]) {
|
if (this.props.isNew && defaultParams[k] === this.state[k]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
params[k] = this.state[k];
|
params[k] = this.state[k];
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default class RegisterForm extends Component {
|
|||||||
|
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type === "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
import { Component } from "../lib/index.js";
|
||||||
|
|
||||||
let store = new Map();
|
let store = new Map();
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@ export default class ScrollManager extends Component {
|
|||||||
|
|
||||||
isAtBottom() {
|
isAtBottom() {
|
||||||
let target = this.props.target.current;
|
let target = this.props.target.current;
|
||||||
return target.scrollTop >= target.scrollHeight - target.offsetHeight;
|
return Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveScrollPosition() {
|
saveScrollPosition(scrollKey) {
|
||||||
let target = this.props.target.current;
|
let target = this.props.target.current;
|
||||||
|
|
||||||
let sticky = target.querySelectorAll(this.props.stickTo);
|
let sticky = target.querySelectorAll(this.props.stickTo);
|
||||||
@@ -29,7 +29,7 @@ export default class ScrollManager extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store.set(this.props.scrollKey, stickToKey);
|
store.set(scrollKey, stickToKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreScrollPosition() {
|
restoreScrollPosition() {
|
||||||
@@ -48,13 +48,13 @@ export default class ScrollManager extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.scrollTop == 0) {
|
if (target.scrollTop === 0) {
|
||||||
this.props.onScrollTop();
|
this.props.onScrollTop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll() {
|
handleScroll() {
|
||||||
if (this.props.target.current.scrollTop == 0) {
|
if (this.props.target.current.scrollTop === 0) {
|
||||||
this.props.onScrollTop();
|
this.props.onScrollTop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,9 +64,9 @@ export default class ScrollManager extends Component {
|
|||||||
this.props.target.current.addEventListener("scroll", this.handleScroll);
|
this.props.target.current.addEventListener("scroll", this.handleScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps(nextProps) {
|
getSnapshotBeforeUpdate(prevProps) {
|
||||||
if (this.props.scrollKey !== nextProps.scrollKey || this.props.children !== nextProps.children) {
|
if (this.props.scrollKey !== prevProps.scrollKey || this.props.children !== prevProps.children) {
|
||||||
this.saveScrollPosition();
|
this.saveScrollPosition(prevProps.scrollKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export default class ScrollManager extends Component {
|
|||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.props.target.current.removeEventListener("scroll", this.handleScroll);
|
this.props.target.current.removeEventListener("scroll", this.handleScroll);
|
||||||
this.saveScrollPosition();
|
this.saveScrollPosition(this.props.scrollKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default class SettingsForm extends Component {
|
|||||||
|
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type === "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value }, () => {
|
this.setState({ [target.name]: value }, () => {
|
||||||
this.props.onChange(this.state);
|
this.props.onChange(this.state);
|
||||||
});
|
});
|
||||||
|
|||||||
171
components/switcher-form.js
Normal file
171
components/switcher-form.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { html, Component } from "../lib/index.js";
|
||||||
|
import { BufferType, getBufferURL, getServerName } from "../state.js";
|
||||||
|
import * as irc from "../lib/irc.js";
|
||||||
|
|
||||||
|
class SwitcherItem extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.handleClick = this.handleClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.props.onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let class_ = this.props.selected ? "selected" : "";
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href=${getBufferURL(this.props.buffer)}
|
||||||
|
class=${class_}
|
||||||
|
onClick=${this.handleClick}
|
||||||
|
>
|
||||||
|
<span class="server">
|
||||||
|
${getServerName(this.props.server, this.props.bouncerNetwork)}
|
||||||
|
</span>
|
||||||
|
${this.props.buffer.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchString(s, query) {
|
||||||
|
return s.toLowerCase().includes(query) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchBuffer(buf, server, query) {
|
||||||
|
let score = 2 * matchString(buf.name, query);
|
||||||
|
switch (buf.type) {
|
||||||
|
case BufferType.CHANNEL:
|
||||||
|
score += matchString(buf.topic || "", query);
|
||||||
|
break;
|
||||||
|
case BufferType.NICK:
|
||||||
|
let user = server.users.get(buf.name);
|
||||||
|
if (user && user.realname && irc.isMeaningfulRealname(user.realname, buf.name)) {
|
||||||
|
score += matchString(user.realname, query);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SwitcherForm extends Component {
|
||||||
|
state = {
|
||||||
|
query: "",
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.handleInput = this.handleInput.bind(this);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSuggestions() {
|
||||||
|
let query = this.state.query.toLowerCase();
|
||||||
|
|
||||||
|
let l = [];
|
||||||
|
let scores = new Map();
|
||||||
|
for (let buf of this.props.buffers.values()) {
|
||||||
|
if (buf.type === BufferType.SERVER) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let score = 0;
|
||||||
|
if (query !== "") {
|
||||||
|
let server = this.props.servers.get(buf.server);
|
||||||
|
score = matchBuffer(buf, server, query);
|
||||||
|
if (!score) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scores.set(buf.id, score);
|
||||||
|
l.push(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
l.sort((a, b) => {
|
||||||
|
return scores.get(b.id) - scores.get(a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return l.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(event) {
|
||||||
|
let target = event.target;
|
||||||
|
this.setState({ [target.name]: target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.props.onSubmit(this.getSuggestions()[this.state.selected]);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown(event) {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
event.stopPropagation();
|
||||||
|
this.move(-1);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
event.stopPropagation();
|
||||||
|
this.move(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
move(delta) {
|
||||||
|
let numSuggestions = this.getSuggestions().length;
|
||||||
|
this.setState((state) => {
|
||||||
|
return {
|
||||||
|
selected: (state.selected + delta + numSuggestions) % numSuggestions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let items = this.getSuggestions().map((buf, i) => {
|
||||||
|
let server = this.props.servers.get(buf.server);
|
||||||
|
|
||||||
|
let bouncerNetwork = null;
|
||||||
|
if (server.bouncerNetID) {
|
||||||
|
bouncerNetwork = this.props.bouncerNetworks.get(server.bouncerNetID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<${SwitcherItem}
|
||||||
|
buffer=${buf}
|
||||||
|
server=${server}
|
||||||
|
bouncerNetwork=${bouncerNetwork}
|
||||||
|
selected=${this.state.selected === i}
|
||||||
|
onClick=${() => this.props.onSubmit(buf)}
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<form
|
||||||
|
onInput=${this.handleInput}
|
||||||
|
onSubmit=${this.handleSubmit}
|
||||||
|
onKeyDown=${this.handleKeyDown}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="query"
|
||||||
|
value=${this.state.query}
|
||||||
|
placeholder="Filter"
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<ul class="switcher-list">
|
||||||
|
${items}
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export default class RegisterForm extends Component {
|
|||||||
|
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type === "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ if (remoteHost) {
|
|||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", () => {
|
client.on("error", (err) => {
|
||||||
|
console.log(err);
|
||||||
ws.close(WS_BAD_GATEWAY);
|
ws.close(WS_BAD_GATEWAY);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
50
doc/config-file.md
Normal file
50
doc/config-file.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Configuration file
|
||||||
|
|
||||||
|
gamja can be configured using a `config.json` file at the root. Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"url": "wss://irc.example.org",
|
||||||
|
"autojoin": "#gamja"
|
||||||
|
},
|
||||||
|
"oauth2": {
|
||||||
|
"url": "https://auth.example.org",
|
||||||
|
"client_id": "asdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors while parsing the configuration file are logged in the
|
||||||
|
[browser's web console].
|
||||||
|
|
||||||
|
## IRC server
|
||||||
|
|
||||||
|
The `server` object configures the IRC server.
|
||||||
|
|
||||||
|
- `url` (string): WebSocket URL or path to connect to. Defaults to `/socket`.
|
||||||
|
- `autojoin` (string or array of strings): Channel(s) to automatically join
|
||||||
|
after connecting.
|
||||||
|
- `auth` (string): configure how the password UI is presented to the user. Set
|
||||||
|
to `mandatory` to require a password, `optional` to accept one but not
|
||||||
|
require it, `disabled` to never ask for a password, `external` to use SASL
|
||||||
|
EXTERNAL, `oauth2` to use SASL OAUTHBEARER. Defaults to `optional`.
|
||||||
|
- `nick` (string): default nickname. If it contains a `*` character, it will be
|
||||||
|
replaced with a random string.
|
||||||
|
- `autoconnect` (boolean): don't display the login UI, immediately connect to
|
||||||
|
the server
|
||||||
|
- `ping` (number): interval in seconds to send PING commands. Set to 0 to
|
||||||
|
disable, this is the default. Enabling PINGs can have an impact on client
|
||||||
|
power usage and should only be enabled if necessary.
|
||||||
|
|
||||||
|
## OAuth 2.0
|
||||||
|
|
||||||
|
The `oauth2` object configures OAuth 2.0 authentication.
|
||||||
|
|
||||||
|
- `url` (string): OAuth 2.0 server URL. The server must support OAuth 2.0
|
||||||
|
Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery.
|
||||||
|
- `client_id` (string): OAuth 2.0 client ID.
|
||||||
|
- `client_secret` (string): OAuth 2.0 client secret.
|
||||||
|
- `scope` (string): OAuth 2.0 scope.
|
||||||
|
|
||||||
|
[browser's web console]: https://firefox-source-docs.mozilla.org/devtools-user/web_console/index.html
|
||||||
68
doc/setup.md
Normal file
68
doc/setup.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Setting up gamja
|
||||||
|
|
||||||
|
An HTTP server must be configured to serve the gamja static files. Usually,
|
||||||
|
the same HTTP server is used as a reverse proxy for the IRC WebSocket.
|
||||||
|
|
||||||
|
## [soju]
|
||||||
|
|
||||||
|
Add a WebSocket listener to soju, e.g. `listen ws+insecure://127.0.0.1:8080`.
|
||||||
|
Then configure your reverse proxy to serve gamja files and proxy `/socket` to
|
||||||
|
soju.
|
||||||
|
|
||||||
|
## [webircgateway]
|
||||||
|
|
||||||
|
Setup webircgateway to serve gamja files:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[fileserving]
|
||||||
|
enabled = true
|
||||||
|
webroot = /path/to/gamja
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure gamja to connect to `/webirc/websocket/` (either by setting
|
||||||
|
`server.url` in the [configuration file], or by appending
|
||||||
|
`?server=/webirc/websocket/` to the URL).
|
||||||
|
|
||||||
|
## nginx
|
||||||
|
|
||||||
|
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
|
||||||
|
timeout to a value higher than the IRC server PING interval. Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
location / {
|
||||||
|
root /path/to/gamja;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /socket {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are unable to configure the proxy timeout accordingly, or if your IRC
|
||||||
|
server doesn't send PINGs, you can set the `server.ping` option in
|
||||||
|
`config.json` (see below).
|
||||||
|
|
||||||
|
## [kimchi]
|
||||||
|
|
||||||
|
Setup kimchi to serve gamja files and proxy the WebSocket connection:
|
||||||
|
|
||||||
|
```
|
||||||
|
site irc.example.org {
|
||||||
|
file_server /path/to/gamja
|
||||||
|
}
|
||||||
|
site irc.example.org/socket {
|
||||||
|
reverse_proxy http://127.0.0.1:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[soju]: https://soju.im
|
||||||
|
[webircgateway]: https://github.com/kiwiirc/webircgateway
|
||||||
|
[kimchi]: https://sr.ht/~emersion/kimchi/
|
||||||
|
[configuration file]: config-file.md
|
||||||
15
doc/url-params.md
Normal file
15
doc/url-params.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# URL parameters
|
||||||
|
|
||||||
|
gamja settings can be overridden using URL query parameters:
|
||||||
|
|
||||||
|
- `server`: path or URL to the WebSocket server
|
||||||
|
- `nick`: nickname (if the character `*` appears in the string, it will be
|
||||||
|
replaced with a randomly generated value)
|
||||||
|
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
|
||||||
|
- `open`: [IRC URL] to open
|
||||||
|
- `debug`: enable debug logs if set to `1`, disable debug logs if set to `0`
|
||||||
|
|
||||||
|
Alternatively, the channels can be set with the URL fragment (ie, by just
|
||||||
|
appending the channel name to the gamja URL).
|
||||||
|
|
||||||
|
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
|
||||||
56
eslint.config.js
Normal file
56
eslint.config.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import globals from "globals";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["dist/"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
"process": "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: { "@stylistic": stylistic },
|
||||||
|
rules: {
|
||||||
|
"no-case-declarations": "off",
|
||||||
|
"no-unused-vars": ["error", {
|
||||||
|
args: "none",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
destructuredArrayIgnorePattern: "^_",
|
||||||
|
}],
|
||||||
|
"no-var": "error",
|
||||||
|
"no-eval": "error",
|
||||||
|
"no-implied-eval": "error",
|
||||||
|
"eqeqeq": "error",
|
||||||
|
"no-invalid-this": "error",
|
||||||
|
"no-extend-native": "error",
|
||||||
|
"prefer-arrow-callback": "error",
|
||||||
|
"no-implicit-globals": "error",
|
||||||
|
"no-throw-literal": "error",
|
||||||
|
"no-implicit-coercion": "warn",
|
||||||
|
"object-shorthand": "warn",
|
||||||
|
"curly": "warn",
|
||||||
|
"camelcase": "warn",
|
||||||
|
"@stylistic/indent": ["warn", "tab", { SwitchCase: 0 }],
|
||||||
|
"@stylistic/quotes": ["warn", "double"],
|
||||||
|
"@stylistic/semi": "warn",
|
||||||
|
"@stylistic/brace-style": ["warn", "1tbs"],
|
||||||
|
"@stylistic/comma-dangle": ["warn", "always-multiline"],
|
||||||
|
"@stylistic/comma-spacing": "warn",
|
||||||
|
"@stylistic/arrow-parens": "warn",
|
||||||
|
"@stylistic/arrow-spacing": "warn",
|
||||||
|
"@stylistic/block-spacing": "warn",
|
||||||
|
"@stylistic/object-curly-spacing": ["warn", "always"],
|
||||||
|
"@stylistic/object-curly-newline": ["warn", {
|
||||||
|
multiline: true,
|
||||||
|
consistent: true,
|
||||||
|
}],
|
||||||
|
"@stylistic/array-bracket-spacing": ["warn", "never"],
|
||||||
|
"@stylistic/array-bracket-newline": ["warn", "consistent"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>gamja IRC client</title>
|
<title>gamja IRC client</title>
|
||||||
<link rel="stylesheet" href="./style.css">
|
<link rel="stylesheet" href="./style.css">
|
||||||
<script type="module" src="./main.js"></script>
|
<script type="module" src="./main.js"></script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReceiptType, Unread, BufferType, SERVER_BUFFER, receiptFromMessage } from "./state.js";
|
import { ReceiptType, Unread, BufferType, receiptFromMessage } from "./state.js";
|
||||||
|
|
||||||
function getSiblingBuffer(buffers, bufID, delta) {
|
function getSiblingBuffer(buffers, bufID, delta) {
|
||||||
let bufList = Array.from(buffers.values());
|
let bufList = Array.from(buffers.values());
|
||||||
@@ -40,6 +40,8 @@ export const keybindings = [
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { buffers };
|
return { buffers };
|
||||||
|
}, () => {
|
||||||
|
app.updateDocumentTitle();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -94,6 +96,14 @@ export const keybindings = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "k",
|
||||||
|
ctrlKey: true,
|
||||||
|
description: "Switch to a buffer",
|
||||||
|
execute: (app) => {
|
||||||
|
app.openDialog("switch");
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function setup(app) {
|
export function setup(app) {
|
||||||
@@ -111,9 +121,9 @@ export function setup(app) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
candidates = candidates.filter((binding) => {
|
candidates = candidates.filter((binding) => {
|
||||||
return !!binding.altKey == event.altKey && !!binding.ctrlKey == event.ctrlKey;
|
return Boolean(binding.altKey) === event.altKey && Boolean(binding.ctrlKey) === event.ctrlKey;
|
||||||
});
|
});
|
||||||
if (candidates.length != 1) {
|
if (candidates.length !== 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function strip(text) {
|
|||||||
if (isDigit(text[i + 1])) {
|
if (isDigit(text[i + 1])) {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
if (text[i + 1] == "," && isDigit(text[i + 2])) {
|
if (text[i + 1] === "," && isDigit(text[i + 2])) {
|
||||||
i += 2;
|
i += 2;
|
||||||
if (isDigit(text[i + 1])) {
|
if (isDigit(text[i + 1])) {
|
||||||
i++;
|
i++;
|
||||||
@@ -63,7 +63,7 @@ export function strip(text) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
i += HEX_COLOR_LENGTH;
|
i += HEX_COLOR_LENGTH;
|
||||||
if (text[i + 1] == "," && isHexColor(text.slice(i + 2))) {
|
if (text[i + 1] === "," && isHexColor(text.slice(i + 2))) {
|
||||||
i += 1 + HEX_COLOR_LENGTH;
|
i += 1 + HEX_COLOR_LENGTH;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -12,26 +12,26 @@ export function encode(data) {
|
|||||||
return btoa(data);
|
return btoa(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
var encoder = new TextEncoder();
|
let encoder = new TextEncoder();
|
||||||
var bytes = encoder.encode(data);
|
let bytes = encoder.encode(data);
|
||||||
|
|
||||||
var trailing = bytes.length % 3;
|
let trailing = bytes.length % 3;
|
||||||
var out = "";
|
let out = "";
|
||||||
for (var i = 0; i < bytes.length - trailing; i += 3) {
|
for (let i = 0; i < bytes.length - trailing; i += 3) {
|
||||||
var u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
|
let u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
|
||||||
out += alphabet[(u24 >> 18) & 0x3F];
|
out += alphabet[(u24 >> 18) & 0x3F];
|
||||||
out += alphabet[(u24 >> 12) & 0x3F];
|
out += alphabet[(u24 >> 12) & 0x3F];
|
||||||
out += alphabet[(u24 >> 6) & 0x3F];
|
out += alphabet[(u24 >> 6) & 0x3F];
|
||||||
out += alphabet[u24 & 0x3F];
|
out += alphabet[u24 & 0x3F];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trailing == 1) {
|
if (trailing === 1) {
|
||||||
var u8 = bytes[bytes.length - 1];
|
let u8 = bytes[bytes.length - 1];
|
||||||
out += alphabet[u8 >> 2];
|
out += alphabet[u8 >> 2];
|
||||||
out += alphabet[(u8 << 4) & 0x3F];
|
out += alphabet[(u8 << 4) & 0x3F];
|
||||||
out += "==";
|
out += "==";
|
||||||
} else if (trailing == 2) {
|
} else if (trailing === 2) {
|
||||||
var u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
|
let u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
|
||||||
out += alphabet[u16 >> 10];
|
out += alphabet[u16 >> 10];
|
||||||
out += alphabet[(u16 >> 4) & 0x3F];
|
out += alphabet[(u16 >> 4) & 0x3F];
|
||||||
out += alphabet[(u16 << 2) & 0x3F];
|
out += alphabet[(u16 << 2) & 0x3F];
|
||||||
|
|||||||
213
lib/client.js
213
lib/client.js
@@ -1,5 +1,4 @@
|
|||||||
import * as irc from "./irc.js";
|
import * as irc from "./irc.js";
|
||||||
import * as base64 from "./base64.js";
|
|
||||||
|
|
||||||
// Static list of capabilities that are always requested when supported by the
|
// Static list of capabilities that are always requested when supported by the
|
||||||
// server
|
// server
|
||||||
@@ -22,6 +21,7 @@ const permanentCaps = [
|
|||||||
"draft/account-registration",
|
"draft/account-registration",
|
||||||
"draft/chathistory",
|
"draft/chathistory",
|
||||||
"draft/extended-monitor",
|
"draft/extended-monitor",
|
||||||
|
"draft/message-redaction",
|
||||||
"draft/read-marker",
|
"draft/read-marker",
|
||||||
|
|
||||||
"soju.im/bouncer-networks",
|
"soju.im/bouncer-networks",
|
||||||
@@ -32,9 +32,33 @@ const RECONNECT_MAX_DELAY_MSEC = 10 * 60 * 1000; // 10min
|
|||||||
|
|
||||||
// WebSocket status codes
|
// WebSocket status codes
|
||||||
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
|
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
|
||||||
const NORMAL_CLOSURE = 1000;
|
const WEBSOCKET_CLOSE_CODES = {
|
||||||
const GOING_AWAY = 1001;
|
NORMAL_CLOSURE: 1000,
|
||||||
const UNSUPPORTED_DATA = 1003;
|
GOING_AWAY: 1001,
|
||||||
|
PROTOCOL_ERROR: 1002,
|
||||||
|
UNSUPPORTED_DATA: 1003,
|
||||||
|
NO_STATUS_CODE: 1005,
|
||||||
|
ABNORMAL_CLOSURE: 1006,
|
||||||
|
INVALID_FRAME_PAYLOAD_DATA: 1007,
|
||||||
|
POLICY_VIOLATION: 1008,
|
||||||
|
MESSAGE_TOO_BIG: 1009,
|
||||||
|
MISSING_MANDATORY_EXT: 1010,
|
||||||
|
INTERNAL_SERVER_ERROR: 1011,
|
||||||
|
TLS_HANDSHAKE_FAILED: 1015,
|
||||||
|
};
|
||||||
|
const WEBSOCKET_CLOSE_CODE_NAMES = {
|
||||||
|
[WEBSOCKET_CLOSE_CODES.GOING_AWAY]: "going away",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.PROTOCOL_ERROR]: "protocol error",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.UNSUPPORTED_DATA]: "unsupported data",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.NO_STATUS_CODE]: "no status code received",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE]: "abnormal closure",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.INVALID_FRAME_PAYLOAD_DATA]: "invalid frame payload data",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.POLICY_VIOLATION]: "policy violation",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.MESSAGE_TOO_BIG]: "message too big",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.MISSING_MANDATORY_EXT]: "missing mandatory extension",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.INTERNAL_SERVER_ERROR]: "internal server error",
|
||||||
|
[WEBSOCKET_CLOSE_CODES.TLS_HANDSHAKE_FAILED]: "TLS handshake failed",
|
||||||
|
};
|
||||||
|
|
||||||
// See https://github.com/quakenet/snircd/blob/master/doc/readme.who
|
// See https://github.com/quakenet/snircd/blob/master/doc/readme.who
|
||||||
// Sorted by order of appearance in RPL_WHOSPCRPL
|
// Sorted by order of appearance in RPL_WHOSPCRPL
|
||||||
@@ -49,6 +73,8 @@ const WHOX_FIELDS = {
|
|||||||
"realname": "r",
|
"realname": "r",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FALLBACK_SERVER_PREFIX = { name: "*" };
|
||||||
|
|
||||||
let lastLabel = 0;
|
let lastLabel = 0;
|
||||||
let lastWhoxToken = 0;
|
let lastWhoxToken = 0;
|
||||||
|
|
||||||
@@ -67,14 +93,26 @@ class IRCError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WebSocketError extends Error {
|
||||||
|
constructor(code) {
|
||||||
|
let text = "Connection error";
|
||||||
|
let name = WEBSOCKET_CLOSE_CODE_NAMES[code];
|
||||||
|
if (name) {
|
||||||
|
text += " (" + name + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
super(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implements a simple exponential backoff.
|
* Implements a simple exponential backoff.
|
||||||
*/
|
*/
|
||||||
class Backoff {
|
class Backoff {
|
||||||
n = 0;
|
n = 0;
|
||||||
|
|
||||||
constructor(min, max) {
|
constructor(base, max) {
|
||||||
this.min = min;
|
this.base = base;
|
||||||
this.max = max;
|
this.max = max;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +123,10 @@ class Backoff {
|
|||||||
next() {
|
next() {
|
||||||
if (this.n === 0) {
|
if (this.n === 0) {
|
||||||
this.n = 1;
|
this.n = 1;
|
||||||
return this.min;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dur = this.n * this.min;
|
let dur = this.n * this.base;
|
||||||
if (dur > this.max) {
|
if (dur > this.max) {
|
||||||
dur = this.max;
|
dur = this.max;
|
||||||
} else {
|
} else {
|
||||||
@@ -108,7 +146,7 @@ export default class Client extends EventTarget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
status = Client.Status.DISCONNECTED;
|
status = Client.Status.DISCONNECTED;
|
||||||
serverPrefix = { name: "*" };
|
serverPrefix = FALLBACK_SERVER_PREFIX;
|
||||||
nick = null;
|
nick = null;
|
||||||
supportsCap = false;
|
supportsCap = false;
|
||||||
caps = new irc.CapRegistry();
|
caps = new irc.CapRegistry();
|
||||||
@@ -133,6 +171,7 @@ export default class Client extends EventTarget {
|
|||||||
autoReconnect = true;
|
autoReconnect = true;
|
||||||
reconnectTimeoutID = null;
|
reconnectTimeoutID = null;
|
||||||
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
|
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
|
||||||
|
lastReconnectDate = new Date(0);
|
||||||
pingIntervalID = null;
|
pingIntervalID = null;
|
||||||
pendingCmds = {
|
pendingCmds = {
|
||||||
WHO: Promise.resolve(null),
|
WHO: Promise.resolve(null),
|
||||||
@@ -146,6 +185,8 @@ export default class Client extends EventTarget {
|
|||||||
constructor(params) {
|
constructor(params) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.handleOnline = this.handleOnline.bind(this);
|
||||||
|
|
||||||
this.params = { ...this.params, ...params };
|
this.params = { ...this.params, ...params };
|
||||||
|
|
||||||
this.reconnect();
|
this.reconnect();
|
||||||
@@ -158,6 +199,7 @@ export default class Client extends EventTarget {
|
|||||||
|
|
||||||
console.log("Connecting to " + this.params.url);
|
console.log("Connecting to " + this.params.url);
|
||||||
this.setStatus(Client.Status.CONNECTING);
|
this.setStatus(Client.Status.CONNECTING);
|
||||||
|
this.lastReconnectDate = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.ws = new WebSocket(this.params.url);
|
this.ws = new WebSocket(this.params.url);
|
||||||
@@ -183,14 +225,14 @@ export default class Client extends EventTarget {
|
|||||||
this.ws.addEventListener("close", (event) => {
|
this.ws.addEventListener("close", (event) => {
|
||||||
console.log("Connection closed (code: " + event.code + ")");
|
console.log("Connection closed (code: " + event.code + ")");
|
||||||
|
|
||||||
if (event.code !== NORMAL_CLOSURE && event.code !== GOING_AWAY) {
|
if (event.code !== WEBSOCKET_CLOSE_CODES.NORMAL_CLOSURE && event.code !== WEBSOCKET_CLOSE_CODES.GOING_AWAY) {
|
||||||
this.dispatchError(new Error("Connection error"));
|
this.dispatchError(new WebSocketError(event.code));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.setStatus(Client.Status.DISCONNECTED);
|
this.setStatus(Client.Status.DISCONNECTED);
|
||||||
this.nick = null;
|
this.nick = null;
|
||||||
this.serverPrefix = null;
|
this.serverPrefix = FALLBACK_SERVER_PREFIX;
|
||||||
this.caps = new irc.CapRegistry();
|
this.caps = new irc.CapRegistry();
|
||||||
this.batches = new Map();
|
this.batches = new Map();
|
||||||
Object.keys(this.pendingCmds).forEach((k) => {
|
Object.keys(this.pendingCmds).forEach((k) => {
|
||||||
@@ -200,15 +242,16 @@ export default class Client extends EventTarget {
|
|||||||
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
|
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
|
||||||
|
|
||||||
if (this.autoReconnect) {
|
if (this.autoReconnect) {
|
||||||
|
window.addEventListener("online", this.handleOnline);
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
console.info("Waiting for network to go back online");
|
console.info("Waiting for network to go back online");
|
||||||
const handleOnline = () => {
|
|
||||||
window.removeEventListener("online", handleOnline);
|
|
||||||
this.reconnect();
|
|
||||||
};
|
|
||||||
window.addEventListener("online", handleOnline);
|
|
||||||
} else {
|
} else {
|
||||||
let delay = this.reconnectBackoff.next();
|
let delay = this.reconnectBackoff.next();
|
||||||
|
let sinceLastReconnect = new Date().getTime() - this.lastReconnectDate.getTime();
|
||||||
|
if (sinceLastReconnect < RECONNECT_MIN_DELAY_MSEC) {
|
||||||
|
delay = Math.max(delay, RECONNECT_MIN_DELAY_MSEC);
|
||||||
|
}
|
||||||
console.info("Reconnecting to server in " + (delay / 1000) + " seconds");
|
console.info("Reconnecting to server in " + (delay / 1000) + " seconds");
|
||||||
clearTimeout(this.reconnectTimeoutID);
|
clearTimeout(this.reconnectTimeoutID);
|
||||||
this.reconnectTimeoutID = setTimeout(() => {
|
this.reconnectTimeoutID = setTimeout(() => {
|
||||||
@@ -225,10 +268,12 @@ export default class Client extends EventTarget {
|
|||||||
clearTimeout(this.reconnectTimeoutID);
|
clearTimeout(this.reconnectTimeoutID);
|
||||||
this.reconnectTimeoutID = null;
|
this.reconnectTimeoutID = null;
|
||||||
|
|
||||||
|
window.removeEventListener("online", this.handleOnline);
|
||||||
|
|
||||||
this.setPingInterval(0);
|
this.setPingInterval(0);
|
||||||
|
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close(NORMAL_CLOSURE);
|
this.ws.close(WEBSOCKET_CLOSE_CODES.NORMAL_CLOSURE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +289,13 @@ export default class Client extends EventTarget {
|
|||||||
this.dispatchEvent(new CustomEvent("error", { detail: err }));
|
this.dispatchEvent(new CustomEvent("error", { detail: err }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOnline() {
|
||||||
|
window.removeEventListener("online", this.handleOnline);
|
||||||
|
if (this.autoReconnect && this.status === Client.Status.DISCONNECTED) {
|
||||||
|
this.reconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleOpen() {
|
handleOpen() {
|
||||||
console.log("Connection opened");
|
console.log("Connection opened");
|
||||||
this.setStatus(Client.Status.REGISTERING);
|
this.setStatus(Client.Status.REGISTERING);
|
||||||
@@ -281,15 +333,17 @@ export default class Client extends EventTarget {
|
|||||||
handleMessage(event) {
|
handleMessage(event) {
|
||||||
if (typeof event.data !== "string") {
|
if (typeof event.data !== "string") {
|
||||||
console.error("Received unsupported data type:", event.data);
|
console.error("Received unsupported data type:", event.data);
|
||||||
this.ws.close(UNSUPPORTED_DATA);
|
this.ws.close(WEBSOCKET_CLOSE_CODES.UNSUPPORTED_DATA);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = irc.parseMessage(event.data);
|
let raw = event.data;
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.debug("Received:", msg);
|
console.debug("Received:", raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let msg = irc.parseMessage(raw);
|
||||||
|
|
||||||
// If the prefix is missing, assume it's coming from the server on the
|
// If the prefix is missing, assume it's coming from the server on the
|
||||||
// other end of the connection
|
// other end of the connection
|
||||||
if (!msg.prefix) {
|
if (!msg.prefix) {
|
||||||
@@ -308,7 +362,6 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let deleteBatch = null;
|
let deleteBatch = null;
|
||||||
let k;
|
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case irc.RPL_WELCOME:
|
case irc.RPL_WELCOME:
|
||||||
if (this.params.saslPlain && !this.supportsCap) {
|
if (this.params.saslPlain && !this.supportsCap) {
|
||||||
@@ -352,7 +405,7 @@ export default class Client extends EventTarget {
|
|||||||
case "AUTHENTICATE":
|
case "AUTHENTICATE":
|
||||||
// Both PLAIN and EXTERNAL expect an empty challenge
|
// Both PLAIN and EXTERNAL expect an empty challenge
|
||||||
let challengeStr = msg.params[0];
|
let challengeStr = msg.params[0];
|
||||||
if (challengeStr != "+") {
|
if (challengeStr !== "+") {
|
||||||
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
|
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
|
||||||
this.send({ command: "AUTHENTICATE", params: ["*"] });
|
this.send({ command: "AUTHENTICATE", params: ["*"] });
|
||||||
}
|
}
|
||||||
@@ -423,7 +476,7 @@ export default class Client extends EventTarget {
|
|||||||
case irc.ERR_NOPERMFORHOST:
|
case irc.ERR_NOPERMFORHOST:
|
||||||
case irc.ERR_YOUREBANNEDCREEP:
|
case irc.ERR_YOUREBANNEDCREEP:
|
||||||
this.dispatchError(new IRCError(msg));
|
this.dispatchError(new IRCError(msg));
|
||||||
if (this.status != Client.Status.REGISTERED) {
|
if (this.status !== Client.Status.REGISTERED) {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -431,7 +484,6 @@ export default class Client extends EventTarget {
|
|||||||
if (this.status === Client.Status.REGISTERED) {
|
if (this.status === Client.Status.REGISTERED) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let reason = msg.params[msg.params.length - 1];
|
|
||||||
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
|
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
|
||||||
this.dispatchError(new Error("Failed to bind to bouncer network", {
|
this.dispatchError(new Error("Failed to bind to bouncer network", {
|
||||||
cause: new IRCError(msg),
|
cause: new IRCError(msg),
|
||||||
@@ -463,18 +515,16 @@ export default class Client extends EventTarget {
|
|||||||
console.log(`Starting SASL ${mechanism} authentication`);
|
console.log(`Starting SASL ${mechanism} authentication`);
|
||||||
|
|
||||||
// Send the first SASL response immediately to avoid a roundtrip
|
// Send the first SASL response immediately to avoid a roundtrip
|
||||||
let initialResp = null;
|
let initialResp;
|
||||||
switch (mechanism) {
|
switch (mechanism) {
|
||||||
case "PLAIN":
|
case "PLAIN":
|
||||||
let respStr = base64.encode("\0" + params.username + "\0" + params.password);
|
initialResp = "\0" + params.username + "\0" + params.password;
|
||||||
initialResp = { command: "AUTHENTICATE", params: [respStr] };
|
|
||||||
break;
|
break;
|
||||||
case "EXTERNAL":
|
case "EXTERNAL":
|
||||||
initialResp = { command: "AUTHENTICATE", params: ["+"] };
|
initialResp = "";
|
||||||
break;
|
break;
|
||||||
case "OAUTHBEARER":
|
case "OAUTHBEARER":
|
||||||
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
|
initialResp = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
|
||||||
initialResp = { command: "AUTHENTICATE", params: [base64.encode(raw)] };
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
|
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
|
||||||
@@ -493,7 +543,9 @@ export default class Client extends EventTarget {
|
|||||||
throw new IRCError(msg);
|
throw new IRCError(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.send(initialResp);
|
for (let msg of irc.generateAuthenticateMessages(initialResp)) {
|
||||||
|
this.send(msg);
|
||||||
|
}
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,16 +579,19 @@ export default class Client extends EventTarget {
|
|||||||
return this.roundtrip(msg, (msg) => {
|
return this.roundtrip(msg, (msg) => {
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case irc.RPL_WHOREPLY:
|
case irc.RPL_WHOREPLY:
|
||||||
|
msg.internal = true;
|
||||||
l.push(this.parseWhoReply(msg));
|
l.push(this.parseWhoReply(msg));
|
||||||
break;
|
break;
|
||||||
case irc.RPL_WHOSPCRPL:
|
case irc.RPL_WHOSPCRPL:
|
||||||
if (msg.params.length !== fields.length || msg.params[1] !== token) {
|
if (msg.params.length !== fields.length + 1 || msg.params[1] !== token) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
msg.internal = true;
|
||||||
l.push(this.parseWhoReply(msg));
|
l.push(this.parseWhoReply(msg));
|
||||||
break;
|
break;
|
||||||
case irc.RPL_ENDOFWHO:
|
case irc.RPL_ENDOFWHO:
|
||||||
if (msg.params[1] === mask) {
|
if (msg.params[1] === mask) {
|
||||||
|
msg.internal = true;
|
||||||
return l;
|
return l;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -652,7 +707,7 @@ export default class Client extends EventTarget {
|
|||||||
switch (subCmd) {
|
switch (subCmd) {
|
||||||
case "LS":
|
case "LS":
|
||||||
this.supportsCap = true;
|
this.supportsCap = true;
|
||||||
if (args[0] == "*") {
|
if (args[0] === "*") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,9 +761,10 @@ export default class Client extends EventTarget {
|
|||||||
if (!this.ws) {
|
if (!this.ws) {
|
||||||
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
|
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
|
||||||
}
|
}
|
||||||
this.ws.send(irc.formatMessage(msg));
|
let raw = irc.formatMessage(msg);
|
||||||
|
this.ws.send(raw);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.debug("Sent:", msg);
|
console.debug("Sent:", raw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +779,7 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isMyNick(nick) {
|
isMyNick(nick) {
|
||||||
return this.cm(nick) == this.cm(this.nick);
|
return this.cm(nick) === this.cm(this.nick);
|
||||||
}
|
}
|
||||||
|
|
||||||
isChannel(name) {
|
isChannel(name) {
|
||||||
@@ -733,7 +789,7 @@ export default class Client extends EventTarget {
|
|||||||
|
|
||||||
isNick(name) {
|
isNick(name) {
|
||||||
// A dollar sign is used for server-wide broadcasts
|
// A dollar sign is used for server-wide broadcasts
|
||||||
return !this.isServer(name) && !this.isChannel(name) && !name.startsWith('$');
|
return !this.isServer(name) && !this.isChannel(name) && !name.startsWith("$");
|
||||||
}
|
}
|
||||||
|
|
||||||
setPingInterval(sec) {
|
setPingInterval(sec) {
|
||||||
@@ -770,7 +826,7 @@ export default class Client extends EventTarget {
|
|||||||
let msg = event.detail.message;
|
let msg = event.detail.message;
|
||||||
|
|
||||||
let msgLabel = irc.getMessageLabel(msg);
|
let msgLabel = irc.getMessageLabel(msg);
|
||||||
if (msgLabel && msgLabel != label) {
|
if (msgLabel && msgLabel !== label) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,7 +875,9 @@ export default class Client extends EventTarget {
|
|||||||
this.removeEventListener("status", handleStatus);
|
this.removeEventListener("status", handleStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.addEventListener("message", handleMessage);
|
// Turn on capture to handle messages before external users and
|
||||||
|
// have the opportunity to set the "internal" flag
|
||||||
|
this.addEventListener("message", handleMessage, { capture: true });
|
||||||
this.addEventListener("status", handleStatus);
|
this.addEventListener("status", handleStatus);
|
||||||
this.send(msg);
|
this.send(msg);
|
||||||
});
|
});
|
||||||
@@ -832,7 +890,7 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
let msg = {
|
let msg = {
|
||||||
command: "JOIN",
|
command: "JOIN",
|
||||||
params: params,
|
params,
|
||||||
};
|
};
|
||||||
return this.roundtrip(msg, (msg) => {
|
return this.roundtrip(msg, (msg) => {
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
@@ -858,7 +916,6 @@ export default class Client extends EventTarget {
|
|||||||
fetchBatch(msg, batchType) {
|
fetchBatch(msg, batchType) {
|
||||||
let batchName = null;
|
let batchName = null;
|
||||||
let messages = [];
|
let messages = [];
|
||||||
let cmd = msg.command;
|
|
||||||
return this.roundtrip(msg, (msg) => {
|
return this.roundtrip(msg, (msg) => {
|
||||||
if (batchName) {
|
if (batchName) {
|
||||||
let batch = msg.batch;
|
let batch = msg.batch;
|
||||||
@@ -902,62 +959,56 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fetch one page of history before the given date. */
|
/* Fetch one page of history before the given date. */
|
||||||
fetchHistoryBefore(target, before, limit) {
|
async fetchHistoryBefore(target, before, limit) {
|
||||||
let max = Math.min(limit, this.isupport.chatHistory());
|
let max = Math.min(limit, this.isupport.chatHistory());
|
||||||
let params = ["BEFORE", target, "timestamp=" + before, max];
|
let params = ["BEFORE", target, "timestamp=" + before, max];
|
||||||
return this.roundtripChatHistory(params).then((messages) => {
|
let messages = await this.roundtripChatHistory(params);
|
||||||
return { messages, more: messages.length >= max };
|
return { messages, more: messages.length >= max };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fetch history in ascending order. */
|
/* Fetch history in ascending order. */
|
||||||
fetchHistoryBetween(target, after, before, limit) {
|
async fetchHistoryBetween(target, after, before, limit) {
|
||||||
let max = Math.min(limit, this.isupport.chatHistory());
|
let max = Math.min(limit, this.isupport.chatHistory());
|
||||||
let params = ["AFTER", target, "timestamp=" + after.time, max];
|
let params = ["AFTER", target, "timestamp=" + after.time, max];
|
||||||
return this.roundtripChatHistory(params).then((messages) => {
|
let messages = await this.roundtripChatHistory(params);
|
||||||
limit -= messages.length;
|
limit -= messages.length;
|
||||||
if (limit <= 0) {
|
if (limit <= 0) {
|
||||||
throw new Error("Cannot fetch all chat history: too many messages");
|
throw new Error("Cannot fetch all chat history: too many messages");
|
||||||
}
|
}
|
||||||
if (messages.length >= max) {
|
if (messages.length >= max) {
|
||||||
// There are still more messages to fetch
|
// There are still more messages to fetch
|
||||||
after.time = messages[messages.length - 1].tags.time;
|
after = { ...after, time: messages[messages.length - 1].tags.time };
|
||||||
return this.fetchHistoryBetween(target, after, before, limit);
|
return await this.fetchHistoryBetween(target, after, before, limit);
|
||||||
}
|
}
|
||||||
return { messages };
|
return { messages };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchHistoryTargets(t1, t2) {
|
async fetchHistoryTargets(t1, t2) {
|
||||||
let msg = {
|
let msg = {
|
||||||
command: "CHATHISTORY",
|
command: "CHATHISTORY",
|
||||||
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
|
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
|
||||||
};
|
};
|
||||||
return this.fetchBatch(msg, "draft/chathistory-targets").then((batch) => {
|
let batch = await this.fetchBatch(msg, "draft/chathistory-targets");
|
||||||
return batch.messages.map((msg) => {
|
return batch.messages.map((msg) => {
|
||||||
if (msg.command != "CHATHISTORY" || msg.params[0] != "TARGETS") {
|
console.assert(msg.command === "CHATHISTORY" && msg.params[0] === "TARGETS");
|
||||||
throw new Error("Cannot fetch chat history targets: unexpected message " + msg);
|
return {
|
||||||
}
|
name: msg.params[1],
|
||||||
return {
|
latestMessage: msg.params[2],
|
||||||
name: msg.params[1],
|
};
|
||||||
latestMessage: msg.params[2],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listBouncerNetworks() {
|
async listBouncerNetworks() {
|
||||||
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
|
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
|
||||||
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
|
let batch = await this.fetchBatch(req, "soju.im/bouncer-networks");
|
||||||
let networks = new Map();
|
let networks = new Map();
|
||||||
for (let msg of batch.messages) {
|
for (let msg of batch.messages) {
|
||||||
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
|
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
|
||||||
let id = msg.params[1];
|
let id = msg.params[1];
|
||||||
let params = irc.parseTags(msg.params[2]);
|
let params = irc.parseTags(msg.params[2]);
|
||||||
networks.set(id, params);
|
networks.set(id, params);
|
||||||
}
|
}
|
||||||
return networks;
|
return networks;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor(target) {
|
monitor(target) {
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import { h } from "../node_modules/preact/dist/preact.module.js";
|
|||||||
import htm from "../node_modules/htm/dist/htm.module.js";
|
import htm from "../node_modules/htm/dist/htm.module.js";
|
||||||
export const html = htm.bind(h);
|
export const html = htm.bind(h);
|
||||||
|
|
||||||
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js";
|
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.mjs";
|
||||||
export { linkifyjs };
|
export { linkifyjs };
|
||||||
|
|||||||
119
lib/irc.js
119
lib/irc.js
@@ -1,3 +1,5 @@
|
|||||||
|
import * as base64 from "./base64.js";
|
||||||
|
|
||||||
// RFC 1459
|
// RFC 1459
|
||||||
export const RPL_WELCOME = "001";
|
export const RPL_WELCOME = "001";
|
||||||
export const RPL_YOURHOST = "002";
|
export const RPL_YOURHOST = "002";
|
||||||
@@ -72,8 +74,24 @@ export const ERR_SASLTOOLONG = "905";
|
|||||||
export const ERR_SASLABORTED = "906";
|
export const ERR_SASLABORTED = "906";
|
||||||
export const ERR_SASLALREADY = "907";
|
export const ERR_SASLALREADY = "907";
|
||||||
|
|
||||||
export const STD_MEMBERSHIPS = "~&@%+";
|
export const STD_MEMBERSHIP_NAMES = {
|
||||||
export const STD_CHANTYPES = "#&+!";
|
"~": "owner",
|
||||||
|
"&": "admin",
|
||||||
|
"@": "operator",
|
||||||
|
"%": "halfop",
|
||||||
|
"+": "voice",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STD_MEMBERSHIP_MODES = {
|
||||||
|
"~": "q",
|
||||||
|
"&": "a",
|
||||||
|
"@": "o",
|
||||||
|
"%": "h",
|
||||||
|
"+": "v",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STD_MEMBERSHIPS = "~&@%+";
|
||||||
|
const STD_CHANTYPES = "#&+!";
|
||||||
|
|
||||||
const tagEscapeMap = {
|
const tagEscapeMap = {
|
||||||
";": "\\:",
|
";": "\\:",
|
||||||
@@ -102,10 +120,10 @@ export function parseTags(s) {
|
|||||||
let parts = s.split("=", 2);
|
let parts = s.split("=", 2);
|
||||||
let k = parts[0];
|
let k = parts[0];
|
||||||
let v = null;
|
let v = null;
|
||||||
if (parts.length == 2) {
|
if (parts.length === 2) {
|
||||||
v = unescapeTag(parts[1]);
|
v = unescapeTag(parts[1]);
|
||||||
if (v.endsWith("\\")) {
|
if (v.endsWith("\\")) {
|
||||||
v = v.slice(0, v.length - 1)
|
v = v.slice(0, v.length - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tags[k] = v;
|
tags[k] = v;
|
||||||
@@ -127,12 +145,6 @@ export function formatTags(tags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parsePrefix(s) {
|
export function parsePrefix(s) {
|
||||||
let prefix = {
|
|
||||||
name: null,
|
|
||||||
user: null,
|
|
||||||
host: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let host = null;
|
let host = null;
|
||||||
let i = s.indexOf("@");
|
let i = s.indexOf("@");
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
@@ -229,7 +241,7 @@ export function formatMessage(msg) {
|
|||||||
s += msg.command;
|
s += msg.command;
|
||||||
if (msg.params && msg.params.length > 0) {
|
if (msg.params && msg.params.length > 0) {
|
||||||
for (let i = 0; i < msg.params.length - 1; i++) {
|
for (let i = 0; i < msg.params.length - 1; i++) {
|
||||||
s += " " + msg.params[i]
|
s += " " + msg.params[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
let last = String(msg.params[msg.params.length - 1]);
|
let last = String(msg.params[msg.params.length - 1]);
|
||||||
@@ -260,10 +272,11 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
|
|||||||
const alphaNum = (() => {
|
const alphaNum = (() => {
|
||||||
try {
|
try {
|
||||||
return new RegExp(/^[\p{L}0-9]$/, "u");
|
return new RegExp(/^[\p{L}0-9]$/, "u");
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
return new RegExp(/^[a-zA-Z0-9]$/, "u");
|
return new RegExp(/^[a-zA-Z0-9]$/, "u");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
const space = new RegExp(/^\s$/);
|
||||||
|
|
||||||
function isWordBoundary(ch) {
|
function isWordBoundary(ch) {
|
||||||
switch (ch) {
|
switch (ch) {
|
||||||
@@ -276,14 +289,39 @@ function isWordBoundary(ch) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isURIPrefix(text) {
|
||||||
|
for (let i = text.length - 1; i >= 0; i--) {
|
||||||
|
if (space.test(text[i])) {
|
||||||
|
text = text.slice(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = text.indexOf("://");
|
||||||
|
if (i <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// See RFC 3986 section 3
|
||||||
|
let ch = text[i - 1];
|
||||||
|
switch (ch) {
|
||||||
|
case "+":
|
||||||
|
case "-":
|
||||||
|
case ".":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return alphaNum.test(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isHighlight(msg, nick, cm) {
|
export function isHighlight(msg, nick, cm) {
|
||||||
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
|
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
nick = cm(nick);
|
nick = cm(nick);
|
||||||
|
|
||||||
if (msg.prefix && cm(msg.prefix.name) == nick) {
|
if (msg.prefix && cm(msg.prefix.name) === nick) {
|
||||||
return false; // Our own messages aren't highlights
|
return false; // Our own messages aren't highlights
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +340,7 @@ export function isHighlight(msg, nick, cm) {
|
|||||||
if (i + nick.length < text.length) {
|
if (i + nick.length < text.length) {
|
||||||
right = text[i + nick.length];
|
right = text[i + nick.length];
|
||||||
}
|
}
|
||||||
if (isWordBoundary(left) && isWordBoundary(right)) {
|
if (isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text.slice(0, i))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +349,7 @@ export function isHighlight(msg, nick, cm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isServerBroadcast(msg) {
|
export function isServerBroadcast(msg) {
|
||||||
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
|
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return msg.params[0].startsWith("$");
|
return msg.params[0].startsWith("$");
|
||||||
@@ -349,7 +387,7 @@ export function formatDate(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseCTCP(msg) {
|
export function parseCTCP(msg) {
|
||||||
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
|
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +507,7 @@ export class Isupport {
|
|||||||
return stdChanModes;
|
return stdChanModes;
|
||||||
}
|
}
|
||||||
let chanModes = this.raw.get("CHANMODES").split(",");
|
let chanModes = this.raw.get("CHANMODES").split(",");
|
||||||
if (chanModes.length != 4) {
|
if (chanModes.length !== 4) {
|
||||||
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
|
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
|
||||||
return stdChanModes;
|
return stdChanModes;
|
||||||
}
|
}
|
||||||
@@ -500,6 +538,10 @@ export class Isupport {
|
|||||||
}
|
}
|
||||||
return parseInt(this.raw.get("LINELEN"), 10);
|
return parseInt(this.raw.get("LINELEN"), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filehost() {
|
||||||
|
return this.raw.get("SOJU.IM/FILEHOST");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMaxPrivmsgLen(isupport, nick, target) {
|
export function getMaxPrivmsgLen(isupport, nick, target) {
|
||||||
@@ -530,13 +572,13 @@ export const CaseMapping = {
|
|||||||
let ch = str[i];
|
let ch = str[i];
|
||||||
if ("A" <= ch && ch <= "Z") {
|
if ("A" <= ch && ch <= "Z") {
|
||||||
ch = ch.toLowerCase();
|
ch = ch.toLowerCase();
|
||||||
} else if (ch == "{") {
|
} else if (ch === "{") {
|
||||||
ch = "[";
|
ch = "[";
|
||||||
} else if (ch == "}") {
|
} else if (ch === "}") {
|
||||||
ch = "]";
|
ch = "]";
|
||||||
} else if (ch == "\\") {
|
} else if (ch === "\\") {
|
||||||
ch = "|";
|
ch = "|";
|
||||||
} else if (ch == "~") {
|
} else if (ch === "~") {
|
||||||
ch = "^";
|
ch = "^";
|
||||||
}
|
}
|
||||||
out += ch;
|
out += ch;
|
||||||
@@ -550,11 +592,11 @@ export const CaseMapping = {
|
|||||||
let ch = str[i];
|
let ch = str[i];
|
||||||
if ("A" <= ch && ch <= "Z") {
|
if ("A" <= ch && ch <= "Z") {
|
||||||
ch = ch.toLowerCase();
|
ch = ch.toLowerCase();
|
||||||
} else if (ch == "{") {
|
} else if (ch === "{") {
|
||||||
ch = "[";
|
ch = "[";
|
||||||
} else if (ch == "}") {
|
} else if (ch === "}") {
|
||||||
ch = "]";
|
ch = "]";
|
||||||
} else if (ch == "\\") {
|
} else if (ch === "\\") {
|
||||||
ch = "|";
|
ch = "|";
|
||||||
}
|
}
|
||||||
out += ch;
|
out += ch;
|
||||||
@@ -839,6 +881,17 @@ export function parseURL(str) {
|
|||||||
return { host, enttype, entity };
|
return { host, enttype, entity };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatURL({ host, enttype, entity } = {}) {
|
||||||
|
host = host || "";
|
||||||
|
entity = entity || "";
|
||||||
|
|
||||||
|
let s = "irc://" + host + "/" + encodeURIComponent(entity);
|
||||||
|
if (enttype) {
|
||||||
|
s += ",is" + enttype;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
export class CapRegistry {
|
export class CapRegistry {
|
||||||
available = new Map();
|
available = new Map();
|
||||||
enabled = new Set();
|
enabled = new Set();
|
||||||
@@ -901,3 +954,19 @@ export class CapRegistry {
|
|||||||
return { command: "CAP", params: ["REQ", l.join(" ")] };
|
return { command: "CAP", params: ["REQ", l.join(" ")] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxSASLLength = 400;
|
||||||
|
|
||||||
|
export function generateAuthenticateMessages(payload) {
|
||||||
|
let encoded = base64.encode(payload);
|
||||||
|
|
||||||
|
// <= instead of < because we need to send a final empty response if the
|
||||||
|
// last chunk is exactly 400 bytes long
|
||||||
|
let msgs = [];
|
||||||
|
for (let i = 0; i <= encoded.length; i += maxSASLLength) {
|
||||||
|
let chunk = encoded.substring(i, i + maxSASLLength);
|
||||||
|
msgs.push({ command: "AUTHENTICATE", params: [chunk || "+"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,36 +4,33 @@ linkifyjs.options.defaults.defaultProtocol = "https";
|
|||||||
|
|
||||||
linkifyjs.registerCustomProtocol("irc");
|
linkifyjs.registerCustomProtocol("irc");
|
||||||
linkifyjs.registerCustomProtocol("ircs");
|
linkifyjs.registerCustomProtocol("ircs");
|
||||||
|
linkifyjs.registerCustomProtocol("geo", true);
|
||||||
|
|
||||||
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser, utils }) => {
|
const IRCChannelToken = linkifyjs.createTokenClass("ircChannel", {
|
||||||
const { POUND, DOMAIN, TLD, LOCALHOST, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
|
isLink: true,
|
||||||
const START_STATE = parser.start;
|
toHref() {
|
||||||
|
return "irc:///" + this.v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const Channel = utils.createTokenClass("ircChannel", {
|
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser }) => {
|
||||||
isLink: true,
|
const { POUND, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
|
||||||
toHref() {
|
const { alphanumeric } = scanner.tokens.groups;
|
||||||
return "irc:///" + this.toString();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const HASH_STATE = START_STATE.tt(POUND);
|
const Prefix = parser.start.tt(POUND);
|
||||||
|
const Channel = new linkifyjs.State(IRCChannelToken);
|
||||||
|
const Divider = Channel.tt(DOT);
|
||||||
|
|
||||||
const CHAN_STATE = HASH_STATE.tt(DOMAIN, Channel);
|
Prefix.ta(alphanumeric, Channel);
|
||||||
HASH_STATE.tt(TLD, CHAN_STATE);
|
Prefix.tt(POUND, Channel);
|
||||||
HASH_STATE.tt(LOCALHOST, CHAN_STATE);
|
Prefix.tt(UNDERSCORE, Channel);
|
||||||
HASH_STATE.tt(POUND, CHAN_STATE);
|
Prefix.tt(DOT, Divider);
|
||||||
|
Prefix.tt(HYPHEN, Channel);
|
||||||
CHAN_STATE.tt(UNDERSCORE, CHAN_STATE);
|
Channel.ta(alphanumeric, Channel);
|
||||||
CHAN_STATE.tt(DOMAIN, CHAN_STATE);
|
Channel.tt(POUND, Channel);
|
||||||
CHAN_STATE.tt(TLD, CHAN_STATE);
|
Channel.tt(UNDERSCORE, Channel);
|
||||||
CHAN_STATE.tt(LOCALHOST, CHAN_STATE);
|
Channel.tt(HYPHEN, Channel);
|
||||||
|
Divider.ta(alphanumeric, Channel);
|
||||||
const CHAN_DIVIDER_STATE = CHAN_STATE.tt(DOT);
|
|
||||||
|
|
||||||
CHAN_DIVIDER_STATE.tt(UNDERSCORE, CHAN_STATE);
|
|
||||||
CHAN_DIVIDER_STATE.tt(DOMAIN, CHAN_STATE);
|
|
||||||
CHAN_DIVIDER_STATE.tt(TLD, CHAN_STATE);
|
|
||||||
CHAN_DIVIDER_STATE.tt(LOCALHOST, CHAN_STATE);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function linkify(text, onClick) {
|
export default function linkify(text, onClick) {
|
||||||
@@ -46,7 +43,7 @@ export default function linkify(text, onClick) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = text.substring(last, match.start)
|
const prefix = text.substring(last, match.start);
|
||||||
children.push(prefix);
|
children.push(prefix);
|
||||||
|
|
||||||
children.push(html`
|
children.push(html`
|
||||||
@@ -61,7 +58,7 @@ export default function linkify(text, onClick) {
|
|||||||
last = match.end;
|
last = match.end;
|
||||||
});
|
});
|
||||||
|
|
||||||
const suffix = text.substring(last)
|
const suffix = text.substring(last);
|
||||||
children.push(suffix);
|
children.push(suffix);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope
|
|||||||
// TODO: use the state param to prevent cross-site request
|
// TODO: use the state param to prevent cross-site request
|
||||||
// forgery
|
// forgery
|
||||||
let params = {
|
let params = {
|
||||||
response_type: "code",
|
"response_type": "code",
|
||||||
client_id: clientId,
|
"client_id": clientId,
|
||||||
redirect_uri: redirectUri,
|
"redirect_uri": redirectUri,
|
||||||
};
|
};
|
||||||
if (scope) {
|
if (scope) {
|
||||||
params.scope = scope;
|
params.scope = scope;
|
||||||
@@ -66,12 +66,12 @@ function buildPostHeaders(clientId, clientSecret) {
|
|||||||
|
|
||||||
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
|
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
|
||||||
let data = {
|
let data = {
|
||||||
grant_type: "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
code,
|
code,
|
||||||
redirect_uri: redirectUri,
|
"redirect_uri": redirectUri,
|
||||||
};
|
};
|
||||||
if (!clientSecret) {
|
if (!clientSecret) {
|
||||||
data.client_id = clientId;
|
data["client_id"] = clientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = await fetch(serverMetadata.token_endpoint, {
|
let resp = await fetch(serverMetadata.token_endpoint, {
|
||||||
|
|||||||
5999
package-lock.json
generated
5999
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -3,12 +3,16 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"htm": "^3.0.4",
|
"htm": "^3.0.4",
|
||||||
"linkifyjs": "^3.0.2",
|
"linkifyjs": "^4.1.3",
|
||||||
"preact": "^10.5.9"
|
"preact": "^10.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.11.1",
|
||||||
"@parcel/packager-raw-url": "^2.0.0",
|
"@parcel/packager-raw-url": "^2.0.0",
|
||||||
"@parcel/transformer-webmanifest": "^2.0.0",
|
"@parcel/transformer-webmanifest": "^2.0.0",
|
||||||
|
"@stylistic/eslint-plugin": "^5.1.0",
|
||||||
|
"eslint": "^9.11.1",
|
||||||
|
"globals": "^17.0.0",
|
||||||
"node-static": "^0.7.11",
|
"node-static": "^0.7.11",
|
||||||
"parcel": "^2.0.0",
|
"parcel": "^2.0.0",
|
||||||
"split": "^1.0.1",
|
"split": "^1.0.1",
|
||||||
@@ -16,12 +20,14 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./dev-server.js",
|
"start": "node ./dev-server.js",
|
||||||
"build": "parcel build"
|
"build": "parcel build",
|
||||||
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"targets": {
|
"targets": {
|
||||||
"default": {
|
"default": {
|
||||||
"source": "index.html",
|
"source": "index.html",
|
||||||
|
"context": "browser",
|
||||||
"publicUrl": "."
|
"publicUrl": "."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
165
state.js
165
state.js
@@ -43,22 +43,14 @@ export const BufferEventsDisplayMode = {
|
|||||||
|
|
||||||
export const SettingsContext = createContext("settings");
|
export const SettingsContext = createContext("settings");
|
||||||
|
|
||||||
export function getNickURL(nick) {
|
|
||||||
return "irc:///" + encodeURIComponent(nick) + ",isuser";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChannelURL(channel) {
|
|
||||||
return "irc:///" + encodeURIComponent(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBufferURL(buf) {
|
export function getBufferURL(buf) {
|
||||||
switch (buf.type) {
|
switch (buf.type) {
|
||||||
case BufferType.SERVER:
|
case BufferType.SERVER:
|
||||||
return "irc:///";
|
return irc.formatURL();
|
||||||
case BufferType.CHANNEL:
|
case BufferType.CHANNEL:
|
||||||
return getChannelURL(buf.name);
|
return irc.formatURL({ entity: buf.name });
|
||||||
case BufferType.NICK:
|
case BufferType.NICK:
|
||||||
return getNickURL(buf.name);
|
return irc.formatURL({ entity: buf.name, enttype: "user" });
|
||||||
}
|
}
|
||||||
throw new Error("Unknown buffer type: " + buf.type);
|
throw new Error("Unknown buffer type: " + buf.type);
|
||||||
}
|
}
|
||||||
@@ -144,22 +136,59 @@ function updateState(state, updater) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isServerBuffer(buf) {
|
function isServerBuffer(buf) {
|
||||||
return buf.type == BufferType.SERVER;
|
return buf.type === BufferType.SERVER;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChannelBuffer(buf) {
|
||||||
|
return buf.type === BufferType.CHANNEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimStartCharacter(s, c) {
|
||||||
|
let i = 0;
|
||||||
|
for (; i < s.length; ++i) {
|
||||||
|
if (s[i] !== c) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.substring(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBouncerNetworkNameFromBuffer(state, buffer) {
|
||||||
|
let server = state.servers.get(buffer.server);
|
||||||
|
let network = state.bouncerNetworks.get(server.bouncerNetID);
|
||||||
|
if (!network) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getServerName(server, network);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Returns 1 if a should appear after b, -1 if a should appear before b, or
|
/* Returns 1 if a should appear after b, -1 if a should appear before b, or
|
||||||
* 0 otherwise. */
|
* 0 otherwise. */
|
||||||
function compareBuffers(a, b) {
|
function compareBuffers(state, a, b) {
|
||||||
if (a.server != b.server) {
|
if (a.server !== b.server) {
|
||||||
|
let aServerName = getBouncerNetworkNameFromBuffer(state, a);
|
||||||
|
let bServerName = getBouncerNetworkNameFromBuffer(state, b);
|
||||||
|
if (aServerName && bServerName && aServerName !== bServerName) {
|
||||||
|
return aServerName.localeCompare(bServerName);
|
||||||
|
}
|
||||||
return a.server > b.server ? 1 : -1;
|
return a.server > b.server ? 1 : -1;
|
||||||
}
|
}
|
||||||
if (isServerBuffer(a) != isServerBuffer(b)) {
|
if (isServerBuffer(a) !== isServerBuffer(b)) {
|
||||||
return isServerBuffer(b) ? 1 : -1;
|
return isServerBuffer(b) ? 1 : -1;
|
||||||
}
|
}
|
||||||
if (a.name != b.name) {
|
|
||||||
return a.name.localeCompare(b.name);
|
if (isChannelBuffer(a) && isChannelBuffer(b)) {
|
||||||
|
const strippedA = trimStartCharacter(a.name, a.name[0]);
|
||||||
|
const strippedB = trimStartCharacter(b.name, b.name[0]);
|
||||||
|
const cmp = strippedA.localeCompare(strippedB);
|
||||||
|
|
||||||
|
if (cmp !== 0) {
|
||||||
|
return cmp;
|
||||||
|
}
|
||||||
|
// if they are the same when stripped, fallthough to default logic
|
||||||
}
|
}
|
||||||
return 0;
|
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMembership(membership, letter, add, client) {
|
function updateMembership(membership, letter, add, client) {
|
||||||
@@ -186,7 +215,7 @@ function updateMembership(membership, letter, add, client) {
|
|||||||
|
|
||||||
/* Insert a message in an immutable list of sorted messages. */
|
/* Insert a message in an immutable list of sorted messages. */
|
||||||
function insertMessage(list, msg) {
|
function insertMessage(list, msg) {
|
||||||
if (list.length == 0) {
|
if (list.length === 0) {
|
||||||
return [msg];
|
return [msg];
|
||||||
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
|
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
|
||||||
return list.concat(msg);
|
return list.concat(msg);
|
||||||
@@ -202,7 +231,7 @@ function insertMessage(list, msg) {
|
|||||||
}
|
}
|
||||||
console.assert(insertBefore >= 0, "");
|
console.assert(insertBefore >= 0, "");
|
||||||
|
|
||||||
list = [ ...list ];
|
list = [...list];
|
||||||
list.splice(insertBefore, 0, msg);
|
list.splice(insertBefore, 0, msg);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -326,7 +355,7 @@ export const State = {
|
|||||||
let id = lastBufferID;
|
let id = lastBufferID;
|
||||||
|
|
||||||
let type;
|
let type;
|
||||||
if (name == SERVER_BUFFER) {
|
if (name === SERVER_BUFFER) {
|
||||||
type = BufferType.SERVER;
|
type = BufferType.SERVER;
|
||||||
} else if (client.isChannel(name)) {
|
} else if (client.isChannel(name)) {
|
||||||
type = BufferType.CHANNEL;
|
type = BufferType.CHANNEL;
|
||||||
@@ -346,10 +375,11 @@ export const State = {
|
|||||||
hasInitialWho: false, // if channel
|
hasInitialWho: false, // if channel
|
||||||
members: new irc.CaseMapMap(null, client.cm), // if channel
|
members: new irc.CaseMapMap(null, client.cm), // if channel
|
||||||
messages: [],
|
messages: [],
|
||||||
|
redacted: new Set(),
|
||||||
unread: Unread.NONE,
|
unread: Unread.NONE,
|
||||||
prevReadReceipt: null,
|
prevReadReceipt: null,
|
||||||
});
|
});
|
||||||
bufferList = bufferList.sort(compareBuffers);
|
bufferList = bufferList.sort((a, b) => compareBuffers(state, a, b));
|
||||||
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
|
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
|
||||||
return [id, { buffers }];
|
return [id, { buffers }];
|
||||||
},
|
},
|
||||||
@@ -402,7 +432,7 @@ export const State = {
|
|||||||
case irc.RPL_ISUPPORT:
|
case irc.RPL_ISUPPORT:
|
||||||
buffers = new Map(state.buffers);
|
buffers = new Map(state.buffers);
|
||||||
state.buffers.forEach((buf) => {
|
state.buffers.forEach((buf) => {
|
||||||
if (buf.server != serverID) {
|
if (buf.server !== serverID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let members = new irc.CaseMapMap(buf.members, client.cm);
|
let members = new irc.CaseMapMap(buf.members, client.cm);
|
||||||
@@ -460,38 +490,41 @@ export const State = {
|
|||||||
});
|
});
|
||||||
return { members };
|
return { members };
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
case irc.RPL_WHOREPLY:
|
|
||||||
case irc.RPL_WHOSPCRPL:
|
|
||||||
who = client.parseWhoReply(msg);
|
|
||||||
|
|
||||||
if (who.flags !== undefined) {
|
|
||||||
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
|
|
||||||
who.operator = who.flags.indexOf("*") >= 0;
|
|
||||||
let botFlag = client.isupport.bot();
|
|
||||||
if (botFlag) {
|
|
||||||
who.bot = who.flags.indexOf(botFlag) >= 0;
|
|
||||||
}
|
|
||||||
delete who.flags;
|
|
||||||
}
|
|
||||||
|
|
||||||
who.offline = false;
|
|
||||||
|
|
||||||
return updateUser(who.nick, who);
|
|
||||||
case irc.RPL_ENDOFWHO:
|
case irc.RPL_ENDOFWHO:
|
||||||
target = msg.params[1];
|
target = msg.params[1];
|
||||||
if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
|
if (msg.list.length === 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
|
||||||
// Not a channel nor a mask, likely a nick
|
// Not a channel nor a mask, likely a nick
|
||||||
return updateUser(target, (user) => {
|
return updateUser(target, (user) => {
|
||||||
return { offline: true };
|
return { offline: true };
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
return updateServer((server) => {
|
||||||
|
let users = new irc.CaseMapMap(server.users);
|
||||||
|
for (let reply of msg.list) {
|
||||||
|
let who = client.parseWhoReply(reply);
|
||||||
|
|
||||||
|
if (who.flags !== undefined) {
|
||||||
|
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
|
||||||
|
who.operator = who.flags.indexOf("*") >= 0;
|
||||||
|
let botFlag = client.isupport.bot();
|
||||||
|
if (botFlag) {
|
||||||
|
who.bot = who.flags.indexOf(botFlag) >= 0;
|
||||||
|
}
|
||||||
|
delete who.flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
who.offline = false;
|
||||||
|
|
||||||
|
users.set(who.nick, who);
|
||||||
|
}
|
||||||
|
return { users };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
|
|
||||||
if (client.isMyNick(msg.prefix.name)) {
|
if (client.isMyNick(msg.prefix.name)) {
|
||||||
let [id, update] = State.createBuffer(state, channel, serverID, client);
|
let [_id, update] = State.createBuffer(state, channel, serverID, client);
|
||||||
state = { ...state, ...update };
|
state = { ...state, ...update };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,7 +582,7 @@ export const State = {
|
|||||||
case "QUIT":
|
case "QUIT":
|
||||||
buffers = new Map(state.buffers);
|
buffers = new Map(state.buffers);
|
||||||
state.buffers.forEach((buf) => {
|
state.buffers.forEach((buf) => {
|
||||||
if (buf.server != serverID) {
|
if (buf.server !== serverID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!buf.members.has(msg.prefix.name)) {
|
if (!buf.members.has(msg.prefix.name)) {
|
||||||
@@ -575,14 +608,15 @@ export const State = {
|
|||||||
|
|
||||||
buffers = new Map(state.buffers);
|
buffers = new Map(state.buffers);
|
||||||
state.buffers.forEach((buf) => {
|
state.buffers.forEach((buf) => {
|
||||||
if (buf.server != serverID) {
|
if (buf.server !== serverID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!buf.members.has(msg.prefix.name)) {
|
let membership = members.get(msg.prefix.name);
|
||||||
|
if (membership === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
members.set(newNick, members.get(msg.prefix.name));
|
members.set(newNick, membership);
|
||||||
members.delete(msg.prefix.name);
|
members.delete(msg.prefix.name);
|
||||||
buffers.set(buf.id, { ...buf, members });
|
buffers.set(buf.id, { ...buf, members });
|
||||||
});
|
});
|
||||||
@@ -616,7 +650,7 @@ export const State = {
|
|||||||
return updateUser(msg.prefix.name, { account });
|
return updateUser(msg.prefix.name, { account });
|
||||||
case "AWAY":
|
case "AWAY":
|
||||||
let awayMessage = msg.params[0];
|
let awayMessage = msg.params[0];
|
||||||
return updateUser(msg.prefix.name, { away: !!awayMessage });
|
return updateUser(msg.prefix.name, { away: Boolean(awayMessage) });
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
topic = msg.params[1];
|
topic = msg.params[1];
|
||||||
@@ -637,32 +671,35 @@ export const State = {
|
|||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
|
|
||||||
irc.forEachChannelModeUpdate(msg, client.isupport, (mode, add, arg) => {
|
irc.forEachChannelModeUpdate(msg, client.isupport, (mode, add, arg) => {
|
||||||
if (prefixByMode.has(mode)) {
|
if (!prefixByMode.has(mode)) {
|
||||||
let nick = arg;
|
return;
|
||||||
let membership = members.get(nick);
|
|
||||||
let letter = prefixByMode.get(mode);
|
|
||||||
members.set(nick, updateMembership(membership, letter, add, client));
|
|
||||||
}
|
}
|
||||||
|
let nick = arg;
|
||||||
|
let membership = members.get(nick);
|
||||||
|
if (membership === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let letter = prefixByMode.get(mode);
|
||||||
|
members.set(nick, updateMembership(membership, letter, add, client));
|
||||||
});
|
});
|
||||||
|
|
||||||
return { members };
|
return { members };
|
||||||
});
|
});
|
||||||
case irc.RPL_MONONLINE:
|
case "REDACT":
|
||||||
targets = msg.params[1].split(",");
|
target = msg.params[0];
|
||||||
|
if (client.isMyNick(target)) {
|
||||||
for (let target of targets) {
|
target = msg.prefix.name;
|
||||||
let prefix = irc.parsePrefix(target);
|
|
||||||
let update = updateUser(prefix.name, { offline: false });
|
|
||||||
state = { ...state, ...update };
|
|
||||||
}
|
}
|
||||||
|
return updateBuffer(target, (buf) => {
|
||||||
return state;
|
return { redacted: new Set(buf.redacted).add(msg.params[1]) };
|
||||||
|
});
|
||||||
|
case irc.RPL_MONONLINE:
|
||||||
case irc.RPL_MONOFFLINE:
|
case irc.RPL_MONOFFLINE:
|
||||||
targets = msg.params[1].split(",");
|
targets = msg.params[1].split(",");
|
||||||
|
|
||||||
for (let target of targets) {
|
for (let target of targets) {
|
||||||
let prefix = irc.parsePrefix(target);
|
let prefix = irc.parsePrefix(target);
|
||||||
let update = updateUser(prefix.name, { offline: true });
|
let update = updateUser(prefix.name, { offline: msg.command === irc.RPL_MONOFFLINE });
|
||||||
state = { ...state, ...update };
|
state = { ...state, ...update };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
store.js
25
store.js
@@ -1,4 +1,4 @@
|
|||||||
import { ReceiptType } from "./state.js";
|
import { ReceiptType, Unread } from "./state.js";
|
||||||
|
|
||||||
const PREFIX = "gamja_";
|
const PREFIX = "gamja_";
|
||||||
|
|
||||||
@@ -62,8 +62,6 @@ export class Buffer {
|
|||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
name: buf.name.toLowerCase(),
|
name: buf.name.toLowerCase(),
|
||||||
server: {
|
server: {
|
||||||
url: buf.server.url,
|
|
||||||
nick: buf.server.nick.toLowerCase(),
|
|
||||||
bouncerNetwork: buf.server.bouncerNetwork,
|
bouncerNetwork: buf.server.bouncerNetwork,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -87,7 +85,7 @@ export class Buffer {
|
|||||||
let updated = !this.m.has(key);
|
let updated = !this.m.has(key);
|
||||||
let prev = this.m.get(key) || {};
|
let prev = this.m.get(key) || {};
|
||||||
|
|
||||||
let unread = prev.unread;
|
let unread = prev.unread || Unread.NONE;
|
||||||
if (buf.unread !== undefined && buf.unread !== prev.unread) {
|
if (buf.unread !== undefined && buf.unread !== prev.unread) {
|
||||||
unread = buf.unread;
|
unread = buf.unread;
|
||||||
updated = true;
|
updated = true;
|
||||||
@@ -109,6 +107,12 @@ export class Buffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let closed = prev.closed || false;
|
||||||
|
if (buf.closed !== undefined && buf.closed !== prev.closed) {
|
||||||
|
closed = buf.closed;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -117,9 +121,8 @@ export class Buffer {
|
|||||||
name: buf.name,
|
name: buf.name,
|
||||||
unread,
|
unread,
|
||||||
receipts,
|
receipts,
|
||||||
|
closed,
|
||||||
server: {
|
server: {
|
||||||
url: buf.server.url,
|
|
||||||
nick: buf.server.nick,
|
|
||||||
bouncerNetwork: buf.server.bouncerNetwork,
|
bouncerNetwork: buf.server.bouncerNetwork,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -134,19 +137,25 @@ export class Buffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list(server) {
|
list(server) {
|
||||||
|
// Some gamja versions would store the same buffer multiple times
|
||||||
|
let names = new Set();
|
||||||
let buffers = [];
|
let buffers = [];
|
||||||
for (const buf of this.m.values()) {
|
for (const buf of this.m.values()) {
|
||||||
if (buf.server.url !== server.url || buf.server.nick !== server.nick || buf.server.bouncerNetwork !== server.bouncerNetwork) {
|
if (buf.server.bouncerNetwork !== server.bouncerNetwork) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (names.has(buf.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
buffers.push(buf);
|
buffers.push(buf);
|
||||||
|
names.add(buf.name);
|
||||||
}
|
}
|
||||||
return buffers;
|
return buffers;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(server) {
|
clear(server) {
|
||||||
if (server) {
|
if (server) {
|
||||||
for (const buf of this.m.values()) {
|
for (const buf of this.list(server)) {
|
||||||
this.m.delete(this.key(buf));
|
this.m.delete(this.key(buf));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
73
style.css
73
style.css
@@ -154,10 +154,6 @@ button.danger:hover {
|
|||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
#buffer-list li.active a {
|
|
||||||
color: white;
|
|
||||||
background-color: var(--gray);
|
|
||||||
}
|
|
||||||
#buffer-list li.error a {
|
#buffer-list li.error a {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
@@ -167,6 +163,10 @@ button.danger:hover {
|
|||||||
#buffer-list li.unread-highlight a {
|
#buffer-list li.unread-highlight a {
|
||||||
color: #22009b;
|
color: #22009b;
|
||||||
}
|
}
|
||||||
|
#buffer-list li.active a {
|
||||||
|
color: white;
|
||||||
|
background-color: var(--gray);
|
||||||
|
}
|
||||||
#buffer-list li:not(.type-server) a {
|
#buffer-list li:not(.type-server) a {
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
@@ -302,7 +302,7 @@ button.danger:hover {
|
|||||||
.membership.admin {
|
.membership.admin {
|
||||||
color: blue;
|
color: blue;
|
||||||
}
|
}
|
||||||
.membership.op {
|
.membership.operator {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
.membership.halfop {
|
.membership.halfop {
|
||||||
@@ -352,20 +352,30 @@ form input[type="text"],
|
|||||||
form input[type="username"],
|
form input[type="username"],
|
||||||
form input[type="password"],
|
form input[type="password"],
|
||||||
form input[type="url"],
|
form input[type="url"],
|
||||||
form input[type="email"] {
|
form input[type="email"],
|
||||||
|
form input[type="search"] {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
form label input[type="checkbox"], form label input[type="radio"] {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
#buffer-list li a, a.timestamp, a.nick {
|
#buffer-list li a, a.timestamp, a.nick {
|
||||||
color: var(--gray);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
#buffer-list li a,
|
||||||
|
a.nick {
|
||||||
|
color: var(--main-color);
|
||||||
|
}
|
||||||
|
a.timestamp {
|
||||||
|
color: var(--gray);
|
||||||
|
}
|
||||||
#buffer-list li a:hover, #buffer-list li a:active,
|
#buffer-list li a:hover, #buffer-list li a:active,
|
||||||
a.timestamp:hover, a.timestamp:active,
|
a.timestamp:hover, a.timestamp:active,
|
||||||
a.nick:hover, a.nick:active {
|
a.nick:hover, a.nick:active {
|
||||||
@@ -387,8 +397,9 @@ details summary[role="button"] {
|
|||||||
}
|
}
|
||||||
#buffer .logline {
|
#buffer .logline {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
#buffer .talk, #buffer .motd {
|
#buffer .talk, #buffer .motd, #buffer .nag {
|
||||||
color: var(--main-color);
|
color: var(--main-color);
|
||||||
}
|
}
|
||||||
#buffer .error {
|
#buffer .error {
|
||||||
@@ -561,21 +572,44 @@ kbd {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul.switcher-list {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
ul.switcher-list li a {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin: 4px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
ul.switcher-list li a.selected {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
ul.switcher-list .server {
|
||||||
|
float: right;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
html {
|
html {
|
||||||
scrollbar-color: var(--gray) transparent;
|
scrollbar-color: var(--gray) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
#buffer-list li.active a {
|
|
||||||
color: var(--sidebar-background);
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
#buffer-list li.unread-message a {
|
#buffer-list li.unread-message a {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
#buffer-list li.unread-highlight a {
|
#buffer-list li.unread-highlight a {
|
||||||
color: #0062cc;
|
color: #0062cc;
|
||||||
}
|
}
|
||||||
|
#buffer-list li.active a {
|
||||||
|
color: var(--sidebar-background);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
#buffer-header .status-gone {
|
#buffer-header .status-gone {
|
||||||
color: #fb885b;
|
color: #fb885b;
|
||||||
@@ -588,7 +622,8 @@ kbd {
|
|||||||
form input[type="username"],
|
form input[type="username"],
|
||||||
form input[type="password"],
|
form input[type="password"],
|
||||||
form input[type="url"],
|
form input[type="url"],
|
||||||
form input[type="email"] {
|
form input[type="email"],
|
||||||
|
form input[type="search"] {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: var(--sidebar-background);
|
background: var(--sidebar-background);
|
||||||
border: 1px solid #495057;
|
border: 1px solid #495057;
|
||||||
@@ -598,16 +633,12 @@ kbd {
|
|||||||
form input[type="username"]:focus,
|
form input[type="username"]:focus,
|
||||||
form input[type="password"]:focus,
|
form input[type="password"]:focus,
|
||||||
form input[type="url"]:focus,
|
form input[type="url"]:focus,
|
||||||
form input[type="email"]:focus {
|
form input[type="email"]:focus,
|
||||||
|
form input[type="search"]:focus {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
border-color: #3897ff;
|
border-color: #3897ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
#buffer-list li a,
|
|
||||||
a.nick {
|
|
||||||
color: var(--main-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#buffer {
|
#buffer {
|
||||||
background: var(--main-background);
|
background: var(--main-background);
|
||||||
}
|
}
|
||||||
@@ -677,6 +708,10 @@ kbd {
|
|||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
box-shadow: inset 0 -1px 0 var(--outline-color);
|
box-shadow: inset 0 -1px 0 var(--outline-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul.switcher-list li a.selected {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user