115 Commits

Author SHA1 Message Date
Simon Ser
488e11eddc eslint: add extra rules 2024-11-16 12:59:42 +01:00
Simon Ser
2f1bf8a2fe lib/client: don't mutate input argument in fetchHistoryBetween() 2024-11-16 12:45:08 +01:00
Simon Ser
75eb175e24 eslint: enable object-shorthand 2024-11-16 12:28:17 +01:00
Simon Ser
db0a69dcfd commands: use Map instead of object 2024-11-16 12:27:34 +01:00
Simon Ser
9c2beac7dd eslint: enable no-implicit-coercion 2024-11-16 12:18:17 +01:00
Simon Ser
8ff1cd8317 eslint: add no-invalid-this and prefer-arrow-callback 2024-11-15 02:19:28 +01:00
Simon Ser
f6e8f83d4e components/app: simplify connectParams object field 2024-11-15 02:19:28 +01:00
Simon Ser
18fa0ebc6a readme: switch from --production to --omit=dev
The former is deprecated.
2024-11-13 01:18:58 +01:00
Simon Ser
afa09cfc25 lib/client: fix typo
That one turned out to be surprisingly tricky to dig out.
2024-11-12 23:11:10 +01:00
Simon Ser
977752e0f2 lib/client: bind handleOnline to this
It's used as a callback to removeEventListener().
2024-11-12 23:10:38 +01:00
Simon Ser
4bce52f162 ci: temporarily switch to alpine/edge
It has a more up-to-date nodejs version which doesn't deadlock in
"npm install".
2024-11-12 23:10:34 +01:00
Simon Ser
75ec7cd212 lib/client: don't throttle reconnections if opened long ago
If a connection was opened a long time ago, and recently got broken,
try to reconnect immediately.
2024-11-12 23:10:30 +01:00
Simon Ser
24e6767cab client: reconnect immediately if network comes online during backoff 2024-11-12 23:10:22 +01:00
Simon Ser
ad165389f0 Fix nick colors in members list
Closes: https://todo.sr.ht/~emersion/gamja/164
2024-11-10 21:04:28 +01:00
Simon Ser
daef362931 Upgrade dependencies 2024-10-23 20:31:08 +02:00
Calvin Lee
3ba0bfe3e6 change sorting of channels in the sidebar 2024-10-23 01:54:51 +00:00
Simon Ser
b67cd10c64 Remove usage of == and != 2024-10-14 00:56:36 +02:00
Simon Ser
205a617c51 Move to Codeberg 2024-10-11 15:07:43 +02:00
Simon Ser
4145907d36 ci: use new SSH key for deployments 2024-10-11 00:25:02 +02:00
Simon Ser
c6e63d5724 components/composer: add support for multiple file upload 2024-09-29 15:31:25 +02:00
xse
2f6efb56de components/composer: handle drag and drop file upload 2024-09-29 15:21:56 +02:00
Simon Ser
cf54beacc2 lint: turn on @stylistic/js/arrow-parens 2024-09-29 11:54:21 +02:00
Simon Ser
69485716a0 lint: turn on @stylistic/js/comma-dangle 2024-09-29 11:50:57 +02:00
Simon Ser
b9d12bc8cd lint: turn on @stylistic/js/semi 2024-09-29 11:49:17 +02:00
Simon Ser
b93db7ac0e ci: fail when ESLint reports warnings 2024-09-29 11:45:56 +02:00
Simon Ser
d96e34da79 Wire up stylistic to ESLint 2024-09-29 11:45:42 +02:00
Simon Ser
78bfd16f25 Remove two remaining single quote strings 2024-09-29 11:45:13 +02:00
Simon Ser
07ae5f7167 Disallow var keyword 2024-09-28 22:07:39 +02:00
Simon Ser
312c755c11 eslint: fix global ignore for dist/ 2024-09-28 21:57:35 +02:00
Simon Ser
a03ad28438 Update dependencies 2024-09-28 21:50:03 +02:00
Simon Ser
f389ea6ffd eslint: ignore dist/ directory 2024-09-28 21:49:13 +02:00
Simon Ser
7c445d0bc9 Add ESLint 2024-09-28 21:45:45 +02:00
Simon Ser
97920ff7f6 Prefix unused variables with an underscore 2024-09-28 21:44:23 +02:00
Simon Ser
b89fd604d0 Remove unnecessary break statements 2024-09-28 21:44:03 +02:00
Simon Ser
6693cc0c78 Remove unused variables 2024-09-28 21:43:23 +02:00
Simon Ser
6747c03a75 components/app: add missing break 2024-09-28 21:36:35 +02:00
Simon Ser
35e924258a components/buffer: drop leading asterisk for MODE messages
This is inconsistent with other messages.
2024-09-08 12:50:00 +02:00
Simon Ser
26792ec386 components/buffer: add human-readable channel mode changes
References: https://todo.sr.ht/~emersion/gamja/162
2024-09-08 12:48:07 +02:00
Simon Ser
a3b375ab3f components/membership: fix operator color 2024-09-08 12:47:37 +02:00
Simon Ser
e1a15ceeb9 components/membership: fix missing import 2024-09-07 17:59:01 +02:00
Simon Ser
9e68316467 components/buffer: use case-mapping when displaying MODE messages 2024-09-07 12:45:49 +02:00
Simon Ser
6be24e8ed9 lib/irc: unexport STD_MEMBERSHIPS and STD_CHANTYPES 2024-09-07 12:37:10 +02:00
Simon Ser
301f133272 lib/irc: move over STD_MEMBERSHIP_NAMES 2024-09-07 12:36:13 +02:00
Simon Ser
9bcfd088c2 components/member-list: remove dead code 2024-09-07 12:31:07 +02:00
Simon Ser
39de184734 readme: accept patches on Codeberg 2024-09-03 21:08:13 +02:00
Simon Ser
2c0f2a80e9 lib/irc: remove stray hardcoded constant 2024-08-13 00:29:00 +02:00
Simon Ser
1c5dc652a9 Downgrade preact to v10.17.1 once again
Seeing this again:

    DOMException: Node.insertBefore: Child to insert before is not a child of this node

Ref https://github.com/preactjs/preact/issues/4221
2024-07-12 09:07:02 +02:00
Simon Ser
b06ebc0267 Upgrade dependencies 2024-07-08 14:43:18 +02:00
Simon Ser
f657a81824 components/buffer-list: fix text color when active and unread
Previously this couldn't happen, but now we don't mark the active
buffer as read when the window doesn't have focus.
2024-07-08 08:33:24 +02:00
Simon Ser
c69869209f components/scroll-manager: relax scroll check
See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
2024-07-02 09:00:30 +02:00
Simon Ser
331a2f0c4e components/scroll-manager: use getSnapshotBeforeUpdate
componentWillReceiveProps is deprecated.
2024-07-02 08:59:43 +02:00
Simon Ser
6c324d44a1 lib/client: add support for AUTHENTICATE chunking
SASL responses need to be split into 400 byte chunks before being
sent to the server.
2024-06-30 23:44:14 +02:00
Simon Ser
d9f7faad88 components/app: warn when username is missing for OAuth introspection
Makes things easier to debug.
2024-06-25 17:54:48 +02:00
Simon Ser
f698d7a250 doc/url-params: document wildcard in nick param 2024-05-09 22:56:58 +02:00
Simon Ser
0f273b9699 components/app: fix STATUSMSG NOTICE when target buffer is closed 2024-04-30 11:22:22 +02:00
Simon Ser
3d03c0dbcf components/app: update prevReadReceipt only when switching buffer
We don't want the unread separator to move around when the tab gets
focus, for instance.
2024-04-25 19:32:10 +02:00
sitting33
0b7726819d Show number of highlights in window title
Co-authored-by: Simon Ser <contact@emersion.fr>
Closes: https://todo.sr.ht/~emersion/gamja/134
2024-04-25 16:49:19 +02:00
Simon Ser
8faff95631 components/app: include bouncer network name in window title
Co-authored-by: sitting33 <me@sit.sh>
2024-04-25 16:34:05 +02:00
Simon Ser
4d6f14ab0b components/app: introduce updateDocumentTitle()
The logic in here will get more involved once we add unread counts
and such.
2024-04-25 16:23:23 +02:00
sitting33
9924f08794 Don't mark messages as read when window is not in focus 2024-04-25 16:07:41 +02:00
sitting33
f79b6bfaa1 components/app: split switchBuffer() and markBufferAsRead()
We'll want to mark as read independently in response to focus events.
2024-04-25 15:59:47 +02:00
Simon Ser
269e034581 Hide replies to our own internal WHO commands
References: https://todo.sr.ht/~emersion/gamja/88
2024-04-17 23:45:44 +02:00
Simon Ser
1ea7c30744 lib/client: fix number of field check in RPL_WHOSPCRPL
The first field is the client.
2024-04-17 23:29:54 +02:00
Simon Ser
87e88cccca Add support for soju.im/filehost
For now, only handle paste events containing files.

Co-authored-by: Alex McGrath <amk@amk.ie>
2024-04-16 13:22:24 +02:00
Simon Ser
97b9efcc9f Upgrade dependencies 2024-04-10 14:24:34 +02:00
Simon Ser
7ec9ae7faa Upgrade preact to v10.20.2 2024-04-09 20:45:22 +02:00
Simon Ser
ebcb731e2f components/buffer: fold NICK change chains 2024-03-29 16:08:04 +01:00
Simon Ser
23ceda5523 Revert "Upgrade preact to v10.20.0"
This reverts commit e843fe3ecb.

Unfortunately the fix doesn't seem to work for us…
2024-03-20 12:07:43 +01:00
Simon Ser
e843fe3ecb Upgrade preact to v10.20.0
The upstream preact bug [1] has been fixed now!

[1]: https://github.com/preactjs/preact/issues/4221
2024-03-20 10:44:11 +01:00
Simon Ser
5171e0010d doc/setup: use plaintext HTTP listener for soju 2024-03-19 14:27:44 +01:00
Ángel Castañeda
5db432b57a docs/setup: proxy pass host header to websocket server 2024-03-19 14:26:57 +01:00
Simon Ser
3584c1eb10 lib/irc: fix whitespace RegExp test in isURIPrefix 2024-03-13 15:41:04 +01:00
Martijn Braam
c1c7c91c38 Prevent zalgo in IRC messages
the overflow: auto; rule is enough to prevent zalgo from drawing over
other IRC messages containing it to a single line.
2024-03-12 09:29:37 +01:00
Simon Ser
2fe2ce6912 lib/irc: fix assignment to undefined variable in isURIPrefix 2024-03-07 23:04:40 +01:00
Simon Ser
57f7b1c011 lib/irc: fix whitespace split in isURIPrefix
We want to get the last index of whitespace, not the first one.
2024-03-07 11:40:37 +01:00
Simon Ser
5d3738bc40 lib/irc: ignore highlights in URLs 2024-03-02 12:36:30 +01:00
Simon Ser
429b4595e7 lib/client: print raw messages in debug mode
Browser consoles aren't super helpful in general and just show
the command name, require extra clicks to see the params.
2024-03-01 15:03:09 +01:00
Simon Ser
038cc68ee4 components/buffer-list: show realname as tooltip 2024-02-20 22:50:09 +01:00
Simon Ser
15cc546876 components/buffer: show realname as tooltip 2024-02-20 22:48:03 +01:00
Simon Ser
a514104c55 commands: drop unvoice
We have devoice already, and that's the one defined in popular
clients such as WeeChat.
2024-02-15 16:40:41 +01:00
Simon Ser
7e5e94cda0 components/help: always show autocomplete keybind 2024-02-15 16:37:32 +01:00
Simon Ser
75d721c02d components/help: add autocomplete 2024-02-15 16:34:16 +01:00
Simon Ser
141fc3e07c Pin preact to v10.17.1
We can't upgrade due to this bug:
https://github.com/preactjs/preact/issues/4221
2024-01-10 20:30:29 +01:00
Simon Ser
b38777e92a lib/linkify: add geo URI scheme 2024-01-10 11:38:23 +01:00
Simon Ser
0640ff8712 Upgrade linkifyjs to v4 2024-01-10 11:33:20 +01:00
Simon Ser
67b2b07506 Upgrade dependencies
Leave preact and linkifyjs alone because they cause breakage.
2024-01-10 10:50:30 +01:00
Simon Ser
15e451f7f8 doc/config-file: indicate where errors are logged 2024-01-07 22:16:56 +01:00
Simon Ser
617a3a7485 Downgrade preact to 10.17.1
References: https://github.com/preactjs/preact/issues/4221
2023-11-29 17:46:16 +01:00
Simon Ser
4f828db244 Downgrade preact to 10.18.2
References: https://github.com/preactjs/preact/issues/4221
2023-11-28 15:13:02 +01:00
Simon Ser
bc19829673 Upgrade dependencies 2023-11-26 18:22:32 +01:00
Simon Ser
5b8c886c91 components/app: throttle our focus PINGs 2023-11-26 16:43:42 +01:00
Simon Ser
c6a1513a07 doc/setup: add kimchi instructions 2023-11-12 10:22:00 +01:00
Simon Ser
3771b39979 doc/setup: improve gamja config instructions for webircgateway 2023-11-12 10:18:57 +01:00
Simon Ser
ca4b3575b1 Move docs to a separate directory 2023-11-12 10:09:50 +01:00
Simon Ser
68cb6c56c4 components/buffer: drop duplicate MONITOR events 2023-11-10 18:11:34 +01:00
Simon Ser
e0de4d1b36 Set targets.default.context in package.json
parcel doesn't auto-detect that field properly due to "engines".

Closes: https://todo.sr.ht/~emersion/gamja/158
2023-10-21 12:51:06 +02:00
Simon Ser
06f7cf9565 Fix ReferenceError when adding new bouncer network
Fixes the following:

    ReferenceError: can't access lexical declaration 'client' before initialization
    handleNetworkSubmit app.js:1868
2023-10-03 11:43:00 +02:00
Simon Ser
5e33919cce Show MONITOR online/offline notifications in user buffers
We were only showing QUIT, which was weird because it wouldn't
say when the user becomes online again. Use MONITOR instead.
2023-08-25 13:10:05 +02:00
Simon Ser
97b5970acb state: simplify MONITOR reply handling 2023-08-25 13:09:14 +02:00
Simon Ser
ffbbde7f28 Upgrade dependencies 2023-08-17 10:50:31 +02:00
Simon Ser
8f29f0c35d lib/client: ensure server prefix is never null
Fixes a null deref in handleChatMessage, because incoming message
prefixes are populated with the server's if null.
2023-08-17 10:45:56 +02:00
Simon Ser
3b383308d4 Send PING on window focus
References: https://todo.sr.ht/~emersion/gamja/148
2023-06-23 16:05:45 +02:00
Simon Ser
3a95fd5ba4 components/switcher-form: match topics and realnames 2023-06-14 11:52:54 +02:00
Simon Ser
44a064274d Add buffer switcher 2023-06-08 15:07:28 +02:00
Simon Ser
fe016807da components/help: fix typo for Ctrl key bindings 2023-06-08 12:15:53 +02:00
Simon Ser
10d988b891 store: fix undefined Unread 2023-06-08 12:09:24 +02:00
Simon Ser
a0ed50a8e2 Keep closed buffers in store
This retains their delivery receipts.

Closes: https://todo.sr.ht/~emersion/gamja/154
2023-06-08 11:54:16 +02:00
Simon Ser
2f627eecad state: handle WHO replies in bulk 2023-04-19 13:04:58 +02:00
Simon Ser
2d651ef901 components/app: prevent multiple WHO channel commands in parallel
References: https://todo.sr.ht/~emersion/gamja/152
2023-04-19 12:56:12 +02:00
Simon Ser
535bdb2f52 Migrate to async/await 2023-04-19 12:51:13 +02:00
Simon Ser
57f64e9cc2 lib/irc: add formatURL 2023-04-19 11:43:45 +02:00
Simon Ser
57809be989 Upgrade dependencies 2023-04-04 19:10:10 +02:00
Simon Ser
6c26ee2156 store: fix clearing buffers for a specific server 2023-04-04 17:00:05 +02:00
Simon Ser
5db0105dbd store: protect against dup buffers 2023-04-04 16:51:23 +02:00
Simon Ser
c8fda8ed53 store: stop matching server URL and nick
This was supposed to accomodate for multi-server support, but to
be honest this is out of scope for gamja.

Closes: https://todo.sr.ht/~emersion/gamja/151
2023-04-04 16:46:58 +02:00
37 changed files with 4204 additions and 3346 deletions

View File

@@ -1,19 +1,25 @@
image: alpine/latest
# TODO switch back to alpine/latest once the "npm install" deadlock is fixed
image: alpine/edge
packages:
- npm
- rsync
sources:
- https://git.sr.ht/~emersion/gamja
- https://codeberg.org/emersion/gamja.git
secrets:
- 77c7956b-003e-44f7-bb5c-2944b2047654 # deploy SSH key
- 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key
tasks:
- setup: |
cd gamja
npm install --include=dev
- build: |
cd gamja
npm run build
- lint: |
cd gamja
npm run -- lint --max-warnings 0
- deploy: |
cd gamja/dist
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
--delete --exclude=config.json \
. deploy@sheeta.emersion.fr:/srv/http/gamja
. deploy-gamja@sheeta.emersion.fr:/srv/http/gamja

119
README.md
View File

@@ -10,54 +10,9 @@ Requires an IRC WebSocket server.
First install dependencies:
npm install --production
npm install --omit=dev
Then configure an HTTP server to serve the gamja files. Below are some
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).
Then [configure an HTTP server] to serve the gamja files.
### Development server
@@ -76,67 +31,13 @@ Optionally, [Parcel] can be used to build a minified version of gamja.
npm install --include=dev
npm run build
## Query parameters
## Configuration
gamja settings can be overridden using URL query 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"
}
}
```
gamja can be configured via a [configuration file] and via [URL parameters].
## 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].
## License
@@ -145,11 +46,11 @@ AGPLv3, see LICENSE.
Copyright (C) 2020 The gamja Contributors
[gamja]: https://sr.ht/~emersion/gamja/
[soju]: https://soju.im
[webircgateway]: https://github.com/kiwiirc/webircgateway
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
[gamja]: https://codeberg.org/emersion/gamja
[Codeberg]: https://codeberg.org/emersion/gamja
[issue tracker]: https://todo.sr.ht/~emersion/gamja
[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

View File

@@ -25,21 +25,20 @@ function getActiveChannel(app) {
return activeBuffer.name;
}
function setUserHostMode(app, args, mode) {
async function setUserHostMode(app, args, mode) {
let nick = args[0];
if (!nick) {
throw new Error("Missing nick");
}
let activeChannel = getActiveChannel(app);
let client = getActiveClient(app);
client.whois(nick).then((whois) => {
const info = whois[irc.RPL_WHOISUSER].params;
const user = info[2];
const host = info[3];
client.send({
command: "MODE",
params: [activeChannel, mode, `*!${user}@${host}`],
});
let whois = await client.whois(nick);
const info = whois[irc.RPL_WHOISUSER].params;
const user = info[2];
const host = info[3];
client.send({
command: "MODE",
params: [activeChannel, mode, `*!${user}@${host}`],
});
}
@@ -54,6 +53,7 @@ function markServerBufferUnread(app) {
}
const join = {
name: "join",
usage: "<name> [password]",
description: "Join a channel",
execute: (app, args) => {
@@ -70,6 +70,7 @@ const join = {
};
const kick = {
name: "kick",
usage: "<nick> [comment]",
description: "Remove a user from the channel",
execute: (app, args) => {
@@ -84,10 +85,11 @@ const kick = {
};
const ban = {
name: "ban",
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => {
if (args.length == 0) {
if (args.length === 0) {
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({
command: "MODE",
@@ -112,20 +114,22 @@ function givemode(app, args, mode) {
});
}
export default {
"away": {
const commands = [
{
name: "away",
usage: "[message]",
description: "Set away message",
execute: (app, args) => {
const params = []
const params = [];
if (args.length) {
params.push(args.join(" "));
}
getActiveClient(app).send({command: "AWAY", params});
},
},
"ban": ban,
"buffer": {
ban,
{
name: "buffer",
usage: "<name>",
description: "Switch to a buffer",
execute: (app, args) => {
@@ -139,39 +143,45 @@ export default {
throw new Error("Unknown buffer");
},
},
"close": {
{
name: "close",
description: "Close the current buffer",
execute: (app, args) => {
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");
}
app.close(activeBuffer.id);
},
},
"deop": {
{
name: "deop",
usage: "<nick>",
description: "Remove operator status for a user on this channel",
execute: (app, args) => givemode(app, args, "-o"),
},
"devoice": {
{
name: "devoice",
usage: "<nick>",
description: "Remove voiced status for a user on this channel",
execute: (app, args) => givemode(app, args, "-v"),
},
"disconnect": {
{
name: "disconnect",
description: "Disconnect from the server",
execute: (app, args) => {
app.disconnect();
},
},
"help": {
{
name: "help",
description: "Show help menu",
execute: (app, args) => {
app.openHelp();
},
},
"invite": {
{
name: "invite",
usage: "<nick>",
description: "Invite a user to the channel",
execute: (app, args) => {
@@ -185,10 +195,11 @@ export default {
]});
},
},
"j": join,
"join": join,
"kick": kick,
"kickban": {
{ ...join, name: "j" },
join,
kick,
{
name: "kickban",
usage: "<target>",
description: "Ban a user and removes them from the channel",
execute: (app, args) => {
@@ -196,7 +207,8 @@ export default {
ban.execute(app, args);
},
},
"lusers": {
{
name: "lusers",
usage: "[<mask> [<target>]]",
description: "Request user statistics about the network",
execute: (app, args) => {
@@ -204,7 +216,8 @@ export default {
markServerBufferUnread(app);
},
},
"me": {
{
name: "me",
usage: "<action>",
description: "Send an action message to the current buffer",
execute: (app, args) => {
@@ -214,7 +227,8 @@ export default {
app.privmsg(target, text);
},
},
"mode": {
{
name: "mode",
usage: "[target] [modes] [mode args...]",
description: "Query or change a channel or user mode",
execute: (app, args) => {
@@ -226,7 +240,8 @@ export default {
getActiveClient(app).send({ command: "MODE", params: args });
},
},
"motd": {
{
name: "motd",
usage: "[server]",
description: "Get the Message Of The Day",
execute: (app, args) => {
@@ -234,7 +249,8 @@ export default {
markServerBufferUnread(app);
},
},
"msg": {
{
name: "msg",
usage: "<target> <message>",
description: "Send a message to a nickname or a channel",
execute: (app, args) => {
@@ -243,7 +259,8 @@ export default {
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
},
},
"nick": {
{
name: "nick",
usage: "<nick>",
description: "Change current nickname",
execute: (app, args) => {
@@ -251,7 +268,8 @@ export default {
getActiveClient(app).send({ command: "NICK", params: [newNick] });
},
},
"notice": {
{
name: "notice",
usage: "<target> <message>",
description: "Send a notice to a nickname or a channel",
execute: (app, args) => {
@@ -260,12 +278,14 @@ export default {
getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
},
},
"op": {
{
name: "op",
usage: "<nick>",
description: "Give a user operator status on this channel",
execute: (app, args) => givemode(app, args, "+o"),
},
"part": {
{
name: "part",
usage: "[reason]",
description: "Leave a channel",
execute: (app, args) => {
@@ -278,7 +298,8 @@ export default {
getActiveClient(app).send({ command: "PART", params });
},
},
"query": {
{
name: "query",
usage: "<nick> [message]",
description: "Open a buffer to send messages to a nickname",
execute: (app, args) => {
@@ -294,11 +315,12 @@ export default {
}
},
},
"quiet": {
{
name: "quiet",
usage: "[nick]",
description: "Quiet a user in the channel, or display the current quiet list",
execute: (app, args) => {
if (args.length == 0) {
if (args.length === 0) {
getActiveClient(app).send({
command: "MODE",
params: [getActiveChannel(app), "+q"],
@@ -308,13 +330,15 @@ export default {
}
},
},
"quit": {
{
name: "quit",
description: "Quit",
execute: (app, args) => {
app.close({ name: SERVER_BUFFER });
},
},
"quote": {
{
name: "quote",
usage: "<command>",
description: "Send a raw IRC command to the server",
execute: (app, args) => {
@@ -327,13 +351,15 @@ export default {
getActiveClient(app).send(msg);
},
},
"reconnect": {
{
name: "reconnect",
description: "Reconnect to the server",
execute: (app, args) => {
app.reconnect();
},
},
"setname": {
{
name: "setname",
usage: "<realname>",
description: "Change current realname",
execute: (app, args) => {
@@ -346,7 +372,8 @@ export default {
// TODO: save to local storage
},
},
"stats": {
{
name: "stats",
usage: "<query> [server]",
description: "Request server statistics",
execute: (app, args) => {
@@ -362,7 +389,8 @@ export default {
markServerBufferUnread(app);
},
},
"topic": {
{
name: "topic",
usage: "<topic>",
description: "Change the topic of the current channel",
execute: (app, args) => {
@@ -374,31 +402,30 @@ export default {
getActiveClient(app).send({ command: "TOPIC", params });
},
},
"unban": {
{
name: "unban",
usage: "<nick>",
description: "Remove a user from the ban list",
execute: (app, args) => {
return setUserHostMode(app, args, "-b");
},
},
"unquiet": {
{
name: "unquiet",
usage: "<nick>",
description: "Remove a user from the quiet list",
execute: (app, args) => {
return setUserHostMode(app, args, "-q");
},
},
"unvoice": {
usage: "<nick>",
description: "Remove a user from the voiced list",
execute: (app, args) => givemode(app, args, "-v"),
},
"voice": {
{
name: "voice",
usage: "<nick>",
description: "Give a user voiced status on this channel",
execute: (app, args) => givemode(app, args, "+v"),
},
"who": {
{
name: "who",
usage: "<mask>",
description: "Retrieve a list of users",
execute: (app, args) => {
@@ -406,7 +433,8 @@ export default {
markServerBufferUnread(app);
},
},
"whois": {
{
name: "whois",
usage: "<nick>",
description: "Retrieve information about a user",
execute: (app, args) => {
@@ -418,7 +446,8 @@ export default {
markServerBufferUnread(app);
},
},
"whowas": {
{
name: "whowas",
usage: "<nick> [count]",
description: "Retrieve information about an offline user",
execute: (app, args) => {
@@ -429,7 +458,8 @@ export default {
markServerBufferUnread(app);
},
},
"list": {
{
name: "list",
usage: "[filter]",
description: "Retrieve a list of channels from a network",
execute: (app, args) => {
@@ -437,4 +467,6 @@ export default {
markServerBufferUnread(app);
},
},
};
];
export default new Map(commands.map((cmd) => [cmd.name, cmd]));

View File

@@ -13,6 +13,7 @@ import AuthForm from "./auth-form.js";
import RegisterForm from "./register-form.js";
import VerifyForm from "./verify-form.js";
import SettingsForm from "./settings-form.js";
import SwitcherForm from "./switcher-form.js";
import Composer from "./composer.js";
import ScrollManager from "./scroll-manager.js";
import Dialog from "./dialog.js";
@@ -54,7 +55,7 @@ function isProduction() {
// NODE_ENV is set by the Parcel build system
try {
return process.env.NODE_ENV === "production";
} catch (err) {
} catch (_err) {
return false;
}
}
@@ -93,7 +94,7 @@ function splitHostPort(str) {
function fillConnectParams(params) {
let host = window.location.host || "localhost:8080";
let proto = "wss:";
if (window.location.protocol != "https:") {
if (window.location.protocol !== "https:") {
proto = "ws:";
}
let path = window.location.pathname || "/";
@@ -199,6 +200,7 @@ export default class App extends Component {
autoOpenURL = null;
messageNotifications = new Set();
baseTitle = null;
lastFocusPingDate = null;
constructor(props) {
super(props);
@@ -226,6 +228,8 @@ export default class App extends Component {
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
this.handleSettingsChange = this.handleSettingsChange.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,
@@ -379,7 +383,7 @@ export default class App extends Component {
}
}
this.setState({ loading: false, connectParams: connectParams });
this.setState({ loading: false, connectParams });
if (connectParams.autoconnect) {
this.setState({ connectForm: false });
@@ -433,6 +437,9 @@ export default class App extends Component {
clientSecret: this.config.oauth2.client_secret,
});
username = data.username;
if (!username) {
console.warn("Username missing from OAuth 2.0 token introspection response");
}
} catch (err) {
console.warn("Failed to introspect OAuth 2.0 token:", err);
}
@@ -489,15 +496,16 @@ export default class App extends Component {
let stored = this.bufferStore.get({ name, server: client.params });
if (client.caps.enabled.has("draft/chathistory") && stored) {
this.setBufferState({ server: serverID, name }, { unread: stored.unread });
}
if (!stored) {
this.bufferStore.put({
name,
server: client.params,
unread: Unread.NONE,
this.setBufferState({ server: serverID, name }, { unread: stored.unread }, () => {
this.updateDocumentTitle();
});
}
this.bufferStore.put({
name,
server: client.params,
closed: false,
});
}
createBuffer(serverID, name) {
@@ -507,7 +515,7 @@ export default class App extends Component {
this.setState((state) => {
let updated;
[id, updated] = State.createBuffer(state, name, serverID, client);
isNew = !!updated;
isNew = Boolean(updated);
return updated;
});
if (isNew) {
@@ -538,13 +546,9 @@ export default class App extends Component {
let client = this.clients.get(buf.server);
let stored = this.bufferStore.get({ name: buf.name, server: client.params });
let prevReadReceipt = getReceipt(stored, ReceiptType.READ);
// TODO: only mark as read if user scrolled at the bottom
let update = State.updateBuffer(state, buf.id, {
unread: Unread.NONE,
prevReadReceipt,
});
let update = State.updateBuffer(state, buf.id, { prevReadReceipt });
return { ...update, activeBuffer: buf.id };
return { activeBuffer: buf.id, ...update };
}, () => {
if (!buf) {
return;
@@ -554,6 +558,35 @@ export default class App extends Component {
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);
for (let notif of this.messageNotifications) {
@@ -575,23 +608,46 @@ export default class App extends Component {
}
}
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);
}
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
document.title = this.baseTitle;
}
this.updateDocumentTitle();
});
}
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) {
// Treat server-wide broadcasts as highlights. They're sent by server
// operators and can contain important information.
@@ -631,7 +687,7 @@ export default class App extends Component {
}
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 text = msg.params[1];
@@ -646,7 +702,7 @@ export default class App extends Component {
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;
if (client.isChannel(bufName)) {
title += " in " + bufName;
@@ -704,7 +760,7 @@ export default class App extends Component {
// Open a new buffer if the message doesn't come from me or is a
// 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.comand !== "QUIT" && msg.command !== irc.RPL_MONONLINE && msg.command !== irc.RPL_MONOFFLINE)) {
this.createBuffer(serverID, bufName);
}
@@ -716,7 +772,7 @@ export default class App extends Component {
let prevReadReceipt = buf.prevReadReceipt;
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);
} else {
receipts[ReceiptType.READ] = receiptFromMessage(msg);
@@ -737,6 +793,10 @@ export default class App extends Component {
this.sendReadReceipt(client, stored);
}
return { unread, prevReadReceipt };
}, () => {
if (msgUnread === Unread.HIGHLIGHT) {
this.updateDocumentTitle();
}
});
}
@@ -846,6 +906,12 @@ export default class App extends Component {
let client = this.clients.get(serverID);
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;
switch (msg.command) {
case "MODE":
@@ -861,7 +927,7 @@ export default class App extends Component {
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
target = SERVER_BUFFER;
} 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 })) {
target = context;
} else {
@@ -870,6 +936,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
// CTCP message
let openNewBuffer = true;
@@ -885,13 +959,6 @@ export default class App extends Component {
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];
case "JOIN":
channel = msg.params[0];
@@ -911,10 +978,10 @@ export default class App extends Component {
affectedBuffers.push(chatHistoryBatch.params[0]);
} else {
this.state.buffers.forEach((buf) => {
if (buf.server != serverID) {
if (buf.server !== serverID) {
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;
}
affectedBuffers.push(buf.name);
@@ -929,7 +996,7 @@ export default class App extends Component {
affectedBuffers.push(chatHistoryBatch.params[0]);
} else {
this.state.buffers.forEach((buf) => {
if (buf.server != serverID) {
if (buf.server !== serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
@@ -970,6 +1037,15 @@ export default class App extends Component {
case irc.RPL_INVITING:
channel = msg.params[2];
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_MYINFO:
case irc.RPL_ISUPPORT:
@@ -981,8 +1057,6 @@ export default class App extends Component {
case irc.RPL_TOPICWHOTIME:
case irc.RPL_NAMREPLY:
case irc.RPL_ENDOFNAMES:
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
case irc.RPL_SASLSUCCESS:
case irc.RPL_CHANNEL_URL:
case "AWAY":
@@ -1029,7 +1103,7 @@ export default class App extends Component {
// Restore opened channel and user buffers
let join = [];
for (let buf of this.bufferStore.list(client.params)) {
if (buf.name === "*") {
if (buf.name === "*" || buf.closed) {
continue;
}
@@ -1068,13 +1142,14 @@ export default class App extends Component {
this.openURL(this.autoOpenURL);
this.autoOpenURL = null;
}
break;
case "JOIN":
channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) {
this.syncBufferUnread(serverID, channel);
}
if (channel == this.switchToChannel) {
if (channel === this.switchToChannel) {
this.switchBuffer({ server: serverID, name: channel });
this.switchToChannel = null;
}
@@ -1167,9 +1242,13 @@ export default class App extends Component {
notif.close();
}
}
let unread;
let closed = true;
this.setBufferState({ server: serverID, name: target }, (buf) => {
closed = false;
// Re-compute unread status
let unread = Unread.NONE;
unread = Unread.NONE;
for (let i = buf.messages.length - 1; i >= 0; i--) {
let msg = buf.messages[i];
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
@@ -1187,18 +1266,20 @@ export default class App extends Component {
unread = Unread.MESSAGE;
}
return { unread };
}, () => {
this.bufferStore.put({
name: target,
server: client.params,
unread,
closed,
receipts: { [ReceiptType.READ]: readReceipt },
});
return { unread };
this.updateDocumentTitle();
});
break;
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];
this.showError(description);
}
@@ -1209,7 +1290,7 @@ export default class App extends Component {
});
}
fetchBacklog(serverID) {
async fetchBacklog(serverID) {
let client = this.clients.get(serverID);
if (!client.caps.enabled.has("draft/chathistory")) {
return;
@@ -1224,45 +1305,48 @@ export default class App extends Component {
}
let now = irc.formatDate(new Date());
client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => {
targets.forEach((target) => {
let from = lastReceipt;
let to = { time: now };
let targets = await client.fetchHistoryTargets(now, lastReceipt.time);
targets.forEach(async (target) => {
let from = lastReceipt;
let to = { time: now };
// Maybe we've just received a READ update from the
// server, avoid over-fetching history
let stored = this.bufferStore.get({ name: target.name, server: client.params });
let readReceipt = getReceipt(stored, ReceiptType.READ);
if (isReceiptBefore(from, readReceipt)) {
from = readReceipt;
// Maybe we've just received a READ update from the
// server, avoid over-fetching history
let stored = this.bufferStore.get({ name: target.name, server: client.params });
let readReceipt = getReceipt(stored, ReceiptType.READ);
if (isReceiptBefore(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 +1451,21 @@ export default class App extends Component {
}
}
whoChannelBuffer(target, serverID) {
async whoChannelBuffer(target, serverID) {
let client = this.clients.get(serverID);
client.who(target, {
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
}).then(() => {
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
});
// Prevent multiple WHO commands for the same channel in parallel
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) {
@@ -1436,7 +1527,7 @@ export default class App extends Component {
servers.delete(buf.server);
let connectForm = state.connectForm;
if (servers.size == 0) {
if (servers.size === 0) {
connectForm = true;
}
@@ -1474,7 +1565,11 @@ export default class App extends Component {
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;
}
}
@@ -1488,7 +1583,7 @@ export default class App extends Component {
let name = parts[0].toLowerCase().slice(1);
let args = parts.slice(1);
let cmd = commands[name];
let cmd = commands.get(name);
if (!cmd) {
this.showError(`Unknown command "${name}" (run "/help" to get a command list)`);
return;
@@ -1503,7 +1598,7 @@ export default class App extends Component {
}
privmsg(target, text) {
if (target == SERVER_BUFFER) {
if (target === SERVER_BUFFER) {
this.showError("Cannot send message in server buffer");
return;
}
@@ -1620,8 +1715,8 @@ export default class App extends Component {
}
if (prefix.startsWith("/")) {
let repl = fromList(Object.keys(commands), prefix.slice(1));
return repl.map(cmd => "/" + cmd);
let repl = fromList([...commands.keys()], prefix.slice(1));
return repl.map((cmd) => "/" + cmd);
}
// TODO: consider using the CHANTYPES ISUPPORT token here
@@ -1646,9 +1741,9 @@ export default class App extends Component {
this.openDialog("help");
}
handleBufferScrollTop() {
async handleBufferScrollTop() {
let buf = this.state.buffers.get(this.state.activeBuffer);
if (!buf || buf.type == BufferType.SERVER) {
if (!buf || buf.type === BufferType.SERVER) {
return;
}
@@ -1676,35 +1771,34 @@ export default class App extends Component {
limit = 200;
}
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
this.endOfHistory.set(buf.id, !result.more);
let result = await client.fetchHistoryBefore(buf.name, before, limit);
this.endOfHistory.set(buf.id, !result.more);
if (result.messages.length > 0) {
let msg = result.messages[result.messages.length - 1];
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer === buf.id) {
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 };
});
if (result.messages.length > 0) {
let msg = result.messages[result.messages.length - 1];
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer === buf.id) {
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 };
});
}
for (let msg of result.messages) {
this.addChatMessage(buf.server, buf.name, msg);
}
});
for (let msg of result.messages) {
this.addChatMessage(buf.server, buf.name, msg);
}
}
openDialog(name, data) {
@@ -1822,12 +1916,13 @@ export default class App extends Component {
});
}
handleNetworkSubmit(attrs, autojoin) {
async handleNetworkSubmit(attrs, autojoin) {
let client = this.clients.values().next().value;
this.dismissDialog();
if (this.state.dialogData && this.state.dialogData.id) {
if (Object.keys(attrs).length == 0) {
this.dismissDialog();
if (Object.keys(attrs).length === 0) {
return;
}
@@ -1837,22 +1932,19 @@ export default class App extends Component {
});
} else {
attrs = { ...attrs, tls: "1" };
client.createBouncerNetwork(attrs).then((id) => {
if (!autojoin) {
return;
}
let id = await client.createBouncerNetwork(attrs);
if (!autojoin) {
return;
}
// By this point, bouncer-networks-notify should've advertised
// the new network
let serverID = this.serverFromBouncerNetwork(id);
let client = this.clients.get(serverID);
client.params.autojoin = [autojoin];
// By this point, bouncer-networks-notify should've advertised
// the new network
let serverID = this.serverFromBouncerNetwork(id);
let newClient = this.clients.get(serverID);
newClient.params.autojoin = [autojoin];
this.switchToChannel = autojoin;
});
this.switchToChannel = autojoin;
}
this.dismissDialog();
}
handleNetworkRemove() {
@@ -1868,7 +1960,7 @@ export default class App extends Component {
handleOpenSettingsClick() {
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")) {
showProtocolHandler = true;
break;
@@ -1888,13 +1980,42 @@ export default class App extends Component {
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()) {
client.send({ command: "PING", params: ["gamja"] });
}
}
componentDidMount() {
this.baseTitle = document.title;
setupKeybindings(this);
window.addEventListener("focus", this.handleWindowFocus);
}
componentWillUnmount() {
document.title = this.baseTitle;
window.removeEventListener("focus", this.handleWindowFocus);
}
render() {
@@ -1917,6 +2038,11 @@ export default class App extends Component {
}
}
let activeClient = null;
if (activeBuffer) {
activeClient = this.clients.get(activeBuffer.server);
}
if (this.state.connectForm) {
let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
@@ -1936,7 +2062,7 @@ export default class App extends Component {
let bufferHeader = null;
if (activeBuffer) {
let activeUser = null;
if (activeBuffer.type == BufferType.NICK) {
if (activeBuffer.type === BufferType.NICK) {
activeUser = activeServer.users.get(activeBuffer.name);
}
@@ -1960,7 +2086,7 @@ export default class App extends Component {
}
let memberList = null;
if (activeBuffer && activeBuffer.type == BufferType.CHANNEL) {
if (activeBuffer && activeBuffer.type === BufferType.CHANNEL) {
memberList = html`
<section
id="member-list"
@@ -2075,6 +2201,17 @@ export default class App extends Component {
</>
`;
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;
@@ -2134,7 +2271,6 @@ export default class App extends Component {
<${Buffer}
buffer=${activeBuffer}
server=${activeServer}
bouncerNetwork=${activeBouncerNetwork}
settings=${this.state.settings}
onChannelClick=${this.handleChannelClick}
onNickClick=${this.handleNickClick}
@@ -2147,6 +2283,7 @@ export default class App extends Component {
${memberList}
<${Composer}
ref=${this.composer}
client=${activeClient}
readOnly=${composerReadOnly}
onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete}

View File

@@ -19,7 +19,7 @@ export default class NetworkForm extends Component {
handleInput(event) {
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 });
}

View File

@@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js";
import { html } from "../lib/index.js";
import linkify from "../lib/linkify.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, ServerStatus, getServerName } from "../state.js";
@@ -214,7 +214,7 @@ export default function BufferHeader(props) {
}
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
if (props.buffer.type === BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork);
}

View File

@@ -1,5 +1,6 @@
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";
function BufferItem(props) {
@@ -15,18 +16,20 @@ function BufferItem(props) {
}
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
if (props.buffer.type === BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork);
}
let title;
let classes = ["type-" + props.buffer.type];
if (props.active) {
classes.push("active");
}
if (props.buffer.unread != Unread.NONE) {
if (props.buffer.unread !== Unread.NONE) {
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;
if (props.bouncerNetwork && props.bouncerNetwork.error) {
isError = true;
@@ -34,12 +37,20 @@ function BufferItem(props) {
if (isError) {
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`
<li class="${classes.join(" ")}">
<a
href=${getBufferURL(props.buffer)}
title=${title}
onClick=${handleClick}
onMouseDown=${handleMouseDown}
>${name}</a>
@@ -47,7 +58,6 @@ function BufferItem(props) {
`;
}
export default function BufferList(props) {
let items = Array.from(props.buffers.values()).map((buf) => {
let server = props.servers.get(buf.server);
@@ -65,7 +75,7 @@ export default function BufferList(props) {
bouncerNetwork=${bouncerNetwork}
onClick=${() => props.onBufferClick(buf)}
onClose=${() => props.onBufferClose(buf)}
active=${props.activeBuffer == buf.id}
active=${props.activeBuffer === buf.id}
/>
`;
});

View File

@@ -2,7 +2,7 @@ import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js";
import * as irc from "../lib/irc.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 Membership from "./membership.js";
@@ -21,9 +21,19 @@ function Nick(props) {
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;
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>
`;
}
@@ -98,12 +108,16 @@ class LogLine extends Component {
function createNick(nick) {
return html`
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
<${Nick}
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`;
}
function createChannel(channel) {
return html`
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
<a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}>
${channel}
</a>
`;
@@ -120,7 +134,7 @@ class LogLine extends Component {
let ctcp = irc.parseCTCP(msg);
if (ctcp) {
if (ctcp.command == "ACTION") {
if (ctcp.command === "ACTION") {
lineClass = "me-tell";
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
} else {
@@ -131,13 +145,12 @@ class LogLine extends Component {
} else {
lineClass = "talk";
let prefix = "<", suffix = ">";
if (msg.command == "NOTICE") {
if (msg.command === "NOTICE") {
prefix = suffix = "-";
}
content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`;
}
let status = null;
let allowedPrefixes = server.statusMsg;
if (target !== buf.name && allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
@@ -182,11 +195,80 @@ class LogLine extends Component {
break;
case "MODE":
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`
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
${user} sets mode ${msg.params.slice(1).join(" ")}
`;
// TODO: case-mapping
if (buf.name !== target) {
if (server.cm(buf.name) !== server.cm(target)) {
content = html`${content} on ${target}`;
}
break;
@@ -267,8 +349,15 @@ class LogLine extends Component {
let date = new Date(parseInt(msg.params[2], 10) * 1000);
content = html`Channel was created on ${date.toLocaleString()}`;
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:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
if (irc.isError(msg.command) && msg.command !== irc.ERR_NOMOTD) {
lineClass = "error";
}
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
@@ -318,11 +407,16 @@ class FoldGroup extends Component {
render() {
let msgs = this.props.messages;
let buf = this.props.buffer;
let server = this.props.server;
let onNickClick = this.props.onNickClick;
function createNick(nick) {
return html`
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
<${Nick}
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`;
}
@@ -578,18 +672,17 @@ export default class Buffer extends Component {
}
let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let settings = this.props.settings;
let serverName = server.name;
let children = [];
if (buf.type == BufferType.SERVER) {
if (buf.type === BufferType.SERVER) {
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}/>`);
}
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`
<${AccountNagger}
server=${server}
@@ -617,7 +710,38 @@ export default class Buffer extends Component {
`;
}
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 keep = [];
msgs.forEach((msg, i) => {
@@ -628,6 +752,8 @@ export default class Buffer extends Component {
keep[partIndexes.get(msg.prefix.name)] = false;
partIndexes.delete(msg.prefix.name);
keep.push(false);
} else if (msg.command === "NICK" && msg.prefix.name === msg.params[0]) {
keep.push(false);
} else {
keep.push(true);
}
@@ -653,6 +779,7 @@ export default class Buffer extends Component {
let hasUnreadSeparator = false;
let prevDate = new Date();
let foldMessages = [];
let lastMonitor = null;
buf.messages.forEach((msg) => {
let sep = [];
@@ -660,7 +787,15 @@ export default class Buffer extends Component {
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"/>`);
hasUnreadSeparator = true;
}

View File

@@ -1,5 +1,16 @@
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 {
state = {
text: "",
@@ -13,6 +24,9 @@ export default class Composer extends Component {
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.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.handleWindowPaste = this.handleWindowPaste.bind(this);
}
@@ -116,6 +130,108 @@ export default class Composer extends Component {
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 = await Promise.all(promises);
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) {
// If an <input> or <button> is focused, ignore.
if (document.activeElement && document.activeElement !== document.body) {
@@ -173,6 +289,11 @@ export default class Composer extends Component {
return;
}
if (event.clipboardData.files.length > 0) {
this.handleInputPaste(event);
return;
}
let text = event.clipboardData.getData("text");
event.preventDefault();
@@ -228,6 +349,9 @@ export default class Composer extends Component {
placeholder=${placeholder}
enterkeyhint="send"
onKeyDown=${this.handleInputKeyDown}
onPaste=${this.handleInputPaste}
onDragOver=${this.handleDragOver}
onDrop=${this.handleDrop}
maxlength=${this.props.maxLen}
/>
</form>

View File

@@ -34,7 +34,7 @@ export default class ConnectForm extends Component {
handleInput(event) {
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 });
}

View File

@@ -21,13 +21,13 @@ export default class Dialog extends Component {
}
handleBackdropClick(event) {
if (event.target.className == "dialog") {
if (event.target.className === "dialog") {
this.dismiss();
}
}
handleKeyDown(event) {
if (event.key == "Escape") {
if (event.key === "Escape") {
this.dismiss();
}
}

View File

@@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js";
import { html } from "../lib/index.js";
import { keybindings } from "../keybindings.js";
import commands from "../commands.js";
@@ -6,7 +6,7 @@ function KeyBindingsHelp() {
let l = keybindings.map((binding) => {
let keys = [];
if (binding.ctrlKey) {
keys.psuh("Ctrl");
keys.push("Ctrl");
}
if (binding.altKey) {
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) {
l.push(html`
<dt><strong>Middle mouse click</strong></dt>

View File

@@ -18,7 +18,7 @@ export default class JoinForm extends Component {
handleInput(event) {
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 });
}

View File

@@ -1,5 +1,4 @@
import { html, Component } from "../lib/index.js";
import { getNickURL } from "../state.js";
import { strip as stripANSI } from "../lib/ansi.js";
import Membership from "./membership.js";
import * as irc from "../lib/irc.js";
@@ -23,26 +22,7 @@ class MemberItem extends Component {
}
render() {
// XXX: If we were feeling creative we could generate unique colors for
// 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 title;
let user = this.props.user;
let classes = ["nick"];
if (user) {
@@ -73,7 +53,7 @@ class MemberItem extends Component {
return html`
<li>
<a
href=${getNickURL(this.props.nick)}
href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })}
class=${classes.join(" ")}
title=${title}
onClick=${this.handleClick}

View File

@@ -1,21 +1,14 @@
import { html, Component } from "../lib/index.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",
};
import { html } from "../lib/index.js";
import * as irc from "../lib/irc.js";
export default function Membership(props) {
if (!this.props.value) {
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`
<span class="membership ${name}" title=${name}>
${this.props.value}

View File

@@ -37,7 +37,7 @@ export default class NetworkForm extends Component {
handleInput(event) {
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 });
}
@@ -46,10 +46,10 @@ export default class NetworkForm extends Component {
let params = {};
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;
}
if (this.props.isNew && defaultParams[k] == this.state[k]) {
if (this.props.isNew && defaultParams[k] === this.state[k]) {
return;
}
params[k] = this.state[k];

View File

@@ -15,7 +15,7 @@ export default class RegisterForm extends Component {
handleInput(event) {
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 });
}

View File

@@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js";
import { Component } from "../lib/index.js";
let store = new Map();
@@ -11,10 +11,10 @@ export default class ScrollManager extends Component {
isAtBottom() {
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 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() {
@@ -48,13 +48,13 @@ export default class ScrollManager extends Component {
}
}
if (target.scrollTop == 0) {
if (target.scrollTop === 0) {
this.props.onScrollTop();
}
}
handleScroll() {
if (this.props.target.current.scrollTop == 0) {
if (this.props.target.current.scrollTop === 0) {
this.props.onScrollTop();
}
}
@@ -64,9 +64,9 @@ export default class ScrollManager extends Component {
this.props.target.current.addEventListener("scroll", this.handleScroll);
}
componentWillReceiveProps(nextProps) {
if (this.props.scrollKey !== nextProps.scrollKey || this.props.children !== nextProps.children) {
this.saveScrollPosition();
getSnapshotBeforeUpdate(prevProps) {
if (this.props.scrollKey !== prevProps.scrollKey || this.props.children !== prevProps.children) {
this.saveScrollPosition(prevProps.scrollKey);
}
}
@@ -79,7 +79,7 @@ export default class ScrollManager extends Component {
componentWillUnmount() {
this.props.target.current.removeEventListener("scroll", this.handleScroll);
this.saveScrollPosition();
this.saveScrollPosition(this.props.scrollKey);
}
render() {

View File

@@ -15,7 +15,7 @@ export default class SettingsForm extends Component {
handleInput(event) {
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.props.onChange(this.state);
});

171
components/switcher-form.js Normal file
View 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.handleKeyUp = this.handleKeyUp.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]);
}
handleKeyUp(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}
onKeyUp=${this.handleKeyUp}
>
<input
type="search"
name="query"
value=${this.state.query}
placeholder="Filter"
autocomplete="off"
autofocus
/>
<ul class="switcher-list">
${items}
</ul>
</form>
`;
}
}

View File

@@ -15,7 +15,7 @@ export default class RegisterForm extends Component {
handleInput(event) {
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 });
}

50
doc/config-file.md Normal file
View 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
View 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
View 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`: 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).
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
import globals from "globals";
import js from "@eslint/js";
import stylisticJs from "@stylistic/eslint-plugin-js";
export default [
{
ignores: ["dist/"],
},
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
"process": "readonly",
},
},
plugins: { "@stylistic/js": stylisticJs },
rules: {
"no-case-declarations": "off",
"no-unused-vars": ["error", {
args: "none",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
}],
"no-var": "error",
"eqeqeq": "error",
"no-invalid-this": "error",
"prefer-arrow-callback": "error",
"require-atomic-updates": "error",
"no-implicit-coercion": "warn",
"object-shorthand": "warn",
//"sort-imports": ["warn", { ignoreMemberSort: true, allowSeparatedGroups: true }],
//"func-style": ["warn", "declaration"],
"@stylistic/js/indent": ["warn", "tab"],
"@stylistic/js/quotes": ["warn", "double"],
"@stylistic/js/semi": "warn",
"@stylistic/js/comma-dangle": ["warn", "always-multiline"],
"@stylistic/js/arrow-parens": "warn",
},
},
];

View File

@@ -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) {
let bufList = Array.from(buffers.values());
@@ -40,6 +40,8 @@ export const keybindings = [
});
});
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) {
@@ -111,9 +121,9 @@ export function setup(app) {
return;
}
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;
}
event.preventDefault();

View File

@@ -51,7 +51,7 @@ export function strip(text) {
if (isDigit(text[i + 1])) {
i++;
}
if (text[i + 1] == "," && isDigit(text[i + 2])) {
if (text[i + 1] === "," && isDigit(text[i + 2])) {
i += 2;
if (isDigit(text[i + 1])) {
i++;
@@ -63,7 +63,7 @@ export function strip(text) {
break;
}
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;
}
break;

View File

@@ -12,26 +12,26 @@ export function encode(data) {
return btoa(data);
}
var encoder = new TextEncoder();
var bytes = encoder.encode(data);
let encoder = new TextEncoder();
let bytes = encoder.encode(data);
var trailing = bytes.length % 3;
var out = "";
for (var i = 0; i < bytes.length - trailing; i += 3) {
var u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
let trailing = bytes.length % 3;
let out = "";
for (let i = 0; i < bytes.length - trailing; i += 3) {
let u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += alphabet[(u24 >> 18) & 0x3F];
out += alphabet[(u24 >> 12) & 0x3F];
out += alphabet[(u24 >> 6) & 0x3F];
out += alphabet[u24 & 0x3F];
}
if (trailing == 1) {
var u8 = bytes[bytes.length - 1];
if (trailing === 1) {
let u8 = bytes[bytes.length - 1];
out += alphabet[u8 >> 2];
out += alphabet[(u8 << 4) & 0x3F];
out += "==";
} else if (trailing == 2) {
var u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
} else if (trailing === 2) {
let u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
out += alphabet[u16 >> 10];
out += alphabet[(u16 >> 4) & 0x3F];
out += alphabet[(u16 << 2) & 0x3F];

View File

@@ -1,5 +1,4 @@
import * as irc from "./irc.js";
import * as base64 from "./base64.js";
// Static list of capabilities that are always requested when supported by the
// server
@@ -49,6 +48,8 @@ const WHOX_FIELDS = {
"realname": "r",
};
const FALLBACK_SERVER_PREFIX = { name: "*" };
let lastLabel = 0;
let lastWhoxToken = 0;
@@ -73,8 +74,8 @@ class IRCError extends Error {
class Backoff {
n = 0;
constructor(min, max) {
this.min = min;
constructor(base, max) {
this.base = base;
this.max = max;
}
@@ -85,10 +86,10 @@ class Backoff {
next() {
if (this.n === 0) {
this.n = 1;
return this.min;
return 0;
}
let dur = this.n * this.min;
let dur = this.n * this.base;
if (dur > this.max) {
dur = this.max;
} else {
@@ -108,7 +109,7 @@ export default class Client extends EventTarget {
};
status = Client.Status.DISCONNECTED;
serverPrefix = { name: "*" };
serverPrefix = FALLBACK_SERVER_PREFIX;
nick = null;
supportsCap = false;
caps = new irc.CapRegistry();
@@ -133,6 +134,7 @@ export default class Client extends EventTarget {
autoReconnect = true;
reconnectTimeoutID = null;
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
lastReconnectDate = new Date(0);
pingIntervalID = null;
pendingCmds = {
WHO: Promise.resolve(null),
@@ -146,6 +148,8 @@ export default class Client extends EventTarget {
constructor(params) {
super();
this.handleOnline = this.handleOnline.bind(this);
this.params = { ...this.params, ...params };
this.reconnect();
@@ -158,6 +162,7 @@ export default class Client extends EventTarget {
console.log("Connecting to " + this.params.url);
this.setStatus(Client.Status.CONNECTING);
this.lastReconnectDate = new Date();
try {
this.ws = new WebSocket(this.params.url);
@@ -190,7 +195,7 @@ export default class Client extends EventTarget {
this.ws = null;
this.setStatus(Client.Status.DISCONNECTED);
this.nick = null;
this.serverPrefix = null;
this.serverPrefix = FALLBACK_SERVER_PREFIX;
this.caps = new irc.CapRegistry();
this.batches = new Map();
Object.keys(this.pendingCmds).forEach((k) => {
@@ -200,15 +205,16 @@ export default class Client extends EventTarget {
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
if (this.autoReconnect) {
window.addEventListener("online", this.handleOnline);
if (!navigator.onLine) {
console.info("Waiting for network to go back online");
const handleOnline = () => {
window.removeEventListener("online", handleOnline);
this.reconnect();
};
window.addEventListener("online", handleOnline);
} else {
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");
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = setTimeout(() => {
@@ -225,6 +231,8 @@ export default class Client extends EventTarget {
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = null;
window.removeEventListener("online", this.handleOnline);
this.setPingInterval(0);
if (this.ws) {
@@ -244,6 +252,13 @@ export default class Client extends EventTarget {
this.dispatchEvent(new CustomEvent("error", { detail: err }));
}
handleOnline() {
window.removeEventListener("online", this.handleOnline);
if (this.autoReconnect && this.status === Client.Status.DISCONNECTED) {
this.reconnect();
}
}
handleOpen() {
console.log("Connection opened");
this.setStatus(Client.Status.REGISTERING);
@@ -285,11 +300,13 @@ export default class Client extends EventTarget {
return;
}
let msg = irc.parseMessage(event.data);
let raw = event.data;
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
// other end of the connection
if (!msg.prefix) {
@@ -308,7 +325,6 @@ export default class Client extends EventTarget {
}
let deleteBatch = null;
let k;
switch (msg.command) {
case irc.RPL_WELCOME:
if (this.params.saslPlain && !this.supportsCap) {
@@ -352,7 +368,7 @@ export default class Client extends EventTarget {
case "AUTHENTICATE":
// Both PLAIN and EXTERNAL expect an empty challenge
let challengeStr = msg.params[0];
if (challengeStr != "+") {
if (challengeStr !== "+") {
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
this.send({ command: "AUTHENTICATE", params: ["*"] });
}
@@ -423,7 +439,7 @@ export default class Client extends EventTarget {
case irc.ERR_NOPERMFORHOST:
case irc.ERR_YOUREBANNEDCREEP:
this.dispatchError(new IRCError(msg));
if (this.status != Client.Status.REGISTERED) {
if (this.status !== Client.Status.REGISTERED) {
this.disconnect();
}
break;
@@ -431,7 +447,6 @@ export default class Client extends EventTarget {
if (this.status === Client.Status.REGISTERED) {
break;
}
let reason = msg.params[msg.params.length - 1];
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
this.dispatchError(new Error("Failed to bind to bouncer network", {
cause: new IRCError(msg),
@@ -463,18 +478,16 @@ export default class Client extends EventTarget {
console.log(`Starting SASL ${mechanism} authentication`);
// Send the first SASL response immediately to avoid a roundtrip
let initialResp = null;
let initialResp;
switch (mechanism) {
case "PLAIN":
let respStr = base64.encode("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
initialResp = "\0" + params.username + "\0" + params.password;
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: ["+"] };
initialResp = "";
break;
case "OAUTHBEARER":
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
initialResp = { command: "AUTHENTICATE", params: [base64.encode(raw)] };
initialResp = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
break;
default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
@@ -493,7 +506,9 @@ export default class Client extends EventTarget {
throw new IRCError(msg);
}
});
this.send(initialResp);
for (let msg of irc.generateAuthenticateMessages(initialResp)) {
this.send(msg);
}
return promise;
}
@@ -527,16 +542,19 @@ export default class Client extends EventTarget {
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.RPL_WHOREPLY:
msg.internal = true;
l.push(this.parseWhoReply(msg));
break;
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;
}
msg.internal = true;
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) {
msg.internal = true;
return l;
}
break;
@@ -652,7 +670,7 @@ export default class Client extends EventTarget {
switch (subCmd) {
case "LS":
this.supportsCap = true;
if (args[0] == "*") {
if (args[0] === "*") {
break;
}
@@ -706,9 +724,10 @@ export default class Client extends EventTarget {
if (!this.ws) {
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) {
console.debug("Sent:", msg);
console.debug("Sent:", raw);
}
}
@@ -723,7 +742,7 @@ export default class Client extends EventTarget {
}
isMyNick(nick) {
return this.cm(nick) == this.cm(this.nick);
return this.cm(nick) === this.cm(this.nick);
}
isChannel(name) {
@@ -733,7 +752,7 @@ export default class Client extends EventTarget {
isNick(name) {
// 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) {
@@ -770,7 +789,7 @@ export default class Client extends EventTarget {
let msg = event.detail.message;
let msgLabel = irc.getMessageLabel(msg);
if (msgLabel && msgLabel != label) {
if (msgLabel && msgLabel !== label) {
return;
}
@@ -819,7 +838,9 @@ export default class Client extends EventTarget {
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.send(msg);
});
@@ -832,7 +853,7 @@ export default class Client extends EventTarget {
}
let msg = {
command: "JOIN",
params: params,
params,
};
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
@@ -858,7 +879,6 @@ export default class Client extends EventTarget {
fetchBatch(msg, batchType) {
let batchName = null;
let messages = [];
let cmd = msg.command;
return this.roundtrip(msg, (msg) => {
if (batchName) {
let batch = msg.batch;
@@ -902,62 +922,56 @@ export default class Client extends EventTarget {
}
/* 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 params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { messages, more: messages.length >= max };
});
let messages = await this.roundtripChatHistory(params);
return { messages, more: messages.length >= max };
}
/* 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 params = ["AFTER", target, "timestamp=" + after.time, max];
return this.roundtripChatHistory(params).then((messages) => {
limit -= messages.length;
if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages");
}
if (messages.length >= max) {
// There are still more messages to fetch
after.time = messages[messages.length - 1].tags.time;
return this.fetchHistoryBetween(target, after, before, limit);
}
return { messages };
});
let messages = await this.roundtripChatHistory(params);
limit -= messages.length;
if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages");
}
if (messages.length >= max) {
// There are still more messages to fetch
after = { ...after, time: messages[messages.length - 1].tags.time };
return await this.fetchHistoryBetween(target, after, before, limit);
}
return { messages };
}
fetchHistoryTargets(t1, t2) {
async fetchHistoryTargets(t1, t2) {
let msg = {
command: "CHATHISTORY",
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
};
return this.fetchBatch(msg, "draft/chathistory-targets").then((batch) => {
return batch.messages.map((msg) => {
if (msg.command != "CHATHISTORY" || msg.params[0] != "TARGETS") {
throw new Error("Cannot fetch chat history targets: unexpected message " + msg);
}
return {
name: msg.params[1],
latestMessage: msg.params[2],
};
});
let batch = await this.fetchBatch(msg, "draft/chathistory-targets");
return batch.messages.map((msg) => {
console.assert(msg.command === "CHATHISTORY" && msg.params[0] === "TARGETS");
return {
name: msg.params[1],
latestMessage: msg.params[2],
};
});
}
listBouncerNetworks() {
async listBouncerNetworks() {
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
let networks = new Map();
for (let msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
let id = msg.params[1];
let params = irc.parseTags(msg.params[2]);
networks.set(id, params);
}
return networks;
});
let batch = await this.fetchBatch(req, "soju.im/bouncer-networks");
let networks = new Map();
for (let msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
let id = msg.params[1];
let params = irc.parseTags(msg.params[2]);
networks.set(id, params);
}
return networks;
}
monitor(target) {

View File

@@ -4,5 +4,5 @@ import { h } from "../node_modules/preact/dist/preact.module.js";
import htm from "../node_modules/htm/dist/htm.module.js";
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.es.js";
export { linkifyjs };

View File

@@ -1,3 +1,5 @@
import * as base64 from "./base64.js";
// RFC 1459
export const RPL_WELCOME = "001";
export const RPL_YOURHOST = "002";
@@ -72,8 +74,24 @@ export const ERR_SASLTOOLONG = "905";
export const ERR_SASLABORTED = "906";
export const ERR_SASLALREADY = "907";
export const STD_MEMBERSHIPS = "~&@%+";
export const STD_CHANTYPES = "#&+!";
export const STD_MEMBERSHIP_NAMES = {
"~": "owner",
"&": "admin",
"@": "operator",
"%": "halfop",
"+": "voice",
};
export const STD_MEMBERSHIP_MODES = {
"~": "q",
"&": "a",
"@": "o",
"%": "h",
"+": "v",
};
const STD_MEMBERSHIPS = "~&@%+";
const STD_CHANTYPES = "#&+!";
const tagEscapeMap = {
";": "\\:",
@@ -102,10 +120,10 @@ export function parseTags(s) {
let parts = s.split("=", 2);
let k = parts[0];
let v = null;
if (parts.length == 2) {
if (parts.length === 2) {
v = unescapeTag(parts[1]);
if (v.endsWith("\\")) {
v = v.slice(0, v.length - 1)
v = v.slice(0, v.length - 1);
}
}
tags[k] = v;
@@ -127,12 +145,6 @@ export function formatTags(tags) {
}
export function parsePrefix(s) {
let prefix = {
name: null,
user: null,
host: null,
};
let host = null;
let i = s.indexOf("@");
if (i > 0) {
@@ -229,7 +241,7 @@ export function formatMessage(msg) {
s += msg.command;
if (msg.params && msg.params.length > 0) {
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]);
@@ -260,10 +272,11 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
const alphaNum = (() => {
try {
return new RegExp(/^[\p{L}0-9]$/, "u");
} catch (e) {
} catch (_e) {
return new RegExp(/^[a-zA-Z0-9]$/, "u");
}
})();
const space = new RegExp(/^\s$/);
function isWordBoundary(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) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return false;
}
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
}
@@ -302,7 +340,7 @@ export function isHighlight(msg, nick, cm) {
if (i + nick.length < text.length) {
right = text[i + nick.length];
}
if (isWordBoundary(left) && isWordBoundary(right)) {
if (isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text.slice(0, i))) {
return true;
}
@@ -311,7 +349,7 @@ export function isHighlight(msg, nick, cm) {
}
export function isServerBroadcast(msg) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return false;
}
return msg.params[0].startsWith("$");
@@ -349,7 +387,7 @@ export function formatDate(date) {
}
export function parseCTCP(msg) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return null;
}
@@ -469,7 +507,7 @@ export class Isupport {
return stdChanModes;
}
let chanModes = this.raw.get("CHANMODES").split(",");
if (chanModes.length != 4) {
if (chanModes.length !== 4) {
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
return stdChanModes;
}
@@ -500,6 +538,10 @@ export class Isupport {
}
return parseInt(this.raw.get("LINELEN"), 10);
}
filehost() {
return this.raw.get("SOJU.IM/FILEHOST");
}
}
export function getMaxPrivmsgLen(isupport, nick, target) {
@@ -530,13 +572,13 @@ export const CaseMapping = {
let ch = str[i];
if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase();
} else if (ch == "{") {
} else if (ch === "{") {
ch = "[";
} else if (ch == "}") {
} else if (ch === "}") {
ch = "]";
} else if (ch == "\\") {
} else if (ch === "\\") {
ch = "|";
} else if (ch == "~") {
} else if (ch === "~") {
ch = "^";
}
out += ch;
@@ -550,11 +592,11 @@ export const CaseMapping = {
let ch = str[i];
if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase();
} else if (ch == "{") {
} else if (ch === "{") {
ch = "[";
} else if (ch == "}") {
} else if (ch === "}") {
ch = "]";
} else if (ch == "\\") {
} else if (ch === "\\") {
ch = "|";
}
out += ch;
@@ -839,6 +881,17 @@ export function parseURL(str) {
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 {
available = new Map();
enabled = new Set();
@@ -901,3 +954,19 @@ export class CapRegistry {
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;
}

View File

@@ -4,36 +4,33 @@ linkifyjs.options.defaults.defaultProtocol = "https";
linkifyjs.registerCustomProtocol("irc");
linkifyjs.registerCustomProtocol("ircs");
linkifyjs.registerCustomProtocol("geo", true);
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser, utils }) => {
const { POUND, DOMAIN, TLD, LOCALHOST, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
const START_STATE = parser.start;
const IRCChannelToken = linkifyjs.createTokenClass("ircChannel", {
isLink: true,
toHref() {
return "irc:///" + this.v;
},
});
const Channel = utils.createTokenClass("ircChannel", {
isLink: true,
toHref() {
return "irc:///" + this.toString();
},
});
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser }) => {
const { POUND, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
const { alphanumeric } = scanner.tokens.groups;
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);
HASH_STATE.tt(TLD, CHAN_STATE);
HASH_STATE.tt(LOCALHOST, CHAN_STATE);
HASH_STATE.tt(POUND, CHAN_STATE);
CHAN_STATE.tt(UNDERSCORE, CHAN_STATE);
CHAN_STATE.tt(DOMAIN, CHAN_STATE);
CHAN_STATE.tt(TLD, CHAN_STATE);
CHAN_STATE.tt(LOCALHOST, CHAN_STATE);
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);
Prefix.ta(alphanumeric, Channel);
Prefix.tt(POUND, Channel);
Prefix.tt(UNDERSCORE, Channel);
Prefix.tt(DOT, Divider);
Prefix.tt(HYPHEN, Channel);
Channel.ta(alphanumeric, Channel);
Channel.tt(POUND, Channel);
Channel.tt(UNDERSCORE, Channel);
Channel.tt(HYPHEN, Channel);
Divider.ta(alphanumeric, Channel);
});
export default function linkify(text, onClick) {
@@ -46,7 +43,7 @@ export default function linkify(text, onClick) {
return;
}
const prefix = text.substring(last, match.start)
const prefix = text.substring(last, match.start);
children.push(prefix);
children.push(html`
@@ -61,7 +58,7 @@ export default function linkify(text, onClick) {
last = match.end;
});
const suffix = text.substring(last)
const suffix = text.substring(last);
children.push(suffix);
return children;

5474
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,16 @@
"type": "module",
"dependencies": {
"htm": "^3.0.4",
"linkifyjs": "^3.0.2",
"preact": "^10.5.9"
"linkifyjs": "^4.1.3",
"preact": "10.17.1"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@parcel/packager-raw-url": "^2.0.0",
"@parcel/transformer-webmanifest": "^2.0.0",
"@stylistic/eslint-plugin-js": "^2.8.0",
"eslint": "^9.11.1",
"globals": "^15.9.0",
"node-static": "^0.7.11",
"parcel": "^2.0.0",
"split": "^1.0.1",
@@ -16,12 +20,14 @@
},
"scripts": {
"start": "node ./dev-server.js",
"build": "parcel build"
"build": "parcel build",
"lint": "eslint"
},
"private": true,
"targets": {
"default": {
"source": "index.html",
"context": "browser",
"publicUrl": "."
}
},

117
state.js
View File

@@ -43,22 +43,14 @@ export const BufferEventsDisplayMode = {
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) {
switch (buf.type) {
case BufferType.SERVER:
return "irc:///";
return irc.formatURL();
case BufferType.CHANNEL:
return getChannelURL(buf.name);
return irc.formatURL({ entity: buf.name });
case BufferType.NICK:
return getNickURL(buf.name);
return irc.formatURL({ entity: buf.name, enttype: "user" });
}
throw new Error("Unknown buffer type: " + buf.type);
}
@@ -144,22 +136,45 @@ function updateState(state, updater) {
}
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);
}
/* Returns 1 if a should appear after b, -1 if a should appear before b, or
* 0 otherwise. */
function compareBuffers(a, b) {
if (a.server != b.server) {
if (a.server !== b.server) {
return a.server > b.server ? 1 : -1;
}
if (isServerBuffer(a) != isServerBuffer(b)) {
if (isServerBuffer(a) !== isServerBuffer(b)) {
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) {
@@ -186,7 +201,7 @@ function updateMembership(membership, letter, add, client) {
/* Insert a message in an immutable list of sorted messages. */
function insertMessage(list, msg) {
if (list.length == 0) {
if (list.length === 0) {
return [msg];
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
return list.concat(msg);
@@ -326,7 +341,7 @@ export const State = {
let id = lastBufferID;
let type;
if (name == SERVER_BUFFER) {
if (name === SERVER_BUFFER) {
type = BufferType.SERVER;
} else if (client.isChannel(name)) {
type = BufferType.CHANNEL;
@@ -402,7 +417,7 @@ export const State = {
case irc.RPL_ISUPPORT:
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
if (buf.server !== serverID) {
return;
}
let members = new irc.CaseMapMap(buf.members, client.cm);
@@ -460,38 +475,41 @@ export const State = {
});
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:
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
return updateUser(target, (user) => {
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":
channel = msg.params[0];
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 };
}
@@ -549,7 +567,7 @@ export const State = {
case "QUIT":
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
if (buf.server !== serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
@@ -575,7 +593,7 @@ export const State = {
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
if (buf.server !== serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
@@ -616,7 +634,7 @@ export const State = {
return updateUser(msg.prefix.name, { account });
case "AWAY":
let awayMessage = msg.params[0];
return updateUser(msg.prefix.name, { away: !!awayMessage });
return updateUser(msg.prefix.name, { away: Boolean(awayMessage) });
case "TOPIC":
channel = msg.params[0];
topic = msg.params[1];
@@ -648,21 +666,12 @@ export const State = {
return { members };
});
case irc.RPL_MONONLINE:
targets = msg.params[1].split(",");
for (let target of targets) {
let prefix = irc.parsePrefix(target);
let update = updateUser(prefix.name, { offline: false });
state = { ...state, ...update };
}
return state;
case irc.RPL_MONOFFLINE:
targets = msg.params[1].split(",");
for (let target of targets) {
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 };
}

View File

@@ -1,4 +1,4 @@
import { ReceiptType } from "./state.js";
import { ReceiptType, Unread } from "./state.js";
const PREFIX = "gamja_";
@@ -62,8 +62,6 @@ export class Buffer {
return JSON.stringify({
name: buf.name.toLowerCase(),
server: {
url: buf.server.url,
nick: buf.server.nick.toLowerCase(),
bouncerNetwork: buf.server.bouncerNetwork,
},
});
@@ -87,7 +85,7 @@ export class Buffer {
let updated = !this.m.has(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) {
unread = buf.unread;
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) {
return false;
}
@@ -117,9 +121,8 @@ export class Buffer {
name: buf.name,
unread,
receipts,
closed,
server: {
url: buf.server.url,
nick: buf.server.nick,
bouncerNetwork: buf.server.bouncerNetwork,
},
});
@@ -134,19 +137,25 @@ export class Buffer {
}
list(server) {
// Some gamja versions would store the same buffer multiple times
let names = new Set();
let buffers = [];
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;
}
buffers.push(buf);
names.add(buf.name);
}
return buffers;
}
clear(server) {
if (server) {
for (const buf of this.m.values()) {
for (const buf of this.list(server)) {
this.m.delete(this.key(buf));
}
} else {

View File

@@ -154,10 +154,6 @@ button.danger:hover {
padding: 2px 10px;
box-sizing: border-box;
}
#buffer-list li.active a {
color: white;
background-color: var(--gray);
}
#buffer-list li.error a {
color: red;
}
@@ -167,6 +163,10 @@ button.danger:hover {
#buffer-list li.unread-highlight a {
color: #22009b;
}
#buffer-list li.active a {
color: white;
background-color: var(--gray);
}
#buffer-list li:not(.type-server) a {
padding-left: 20px;
}
@@ -302,7 +302,7 @@ button.danger:hover {
.membership.admin {
color: blue;
}
.membership.op {
.membership.operator {
color: var(--green);
}
.membership.halfop {
@@ -352,7 +352,8 @@ form input[type="text"],
form input[type="username"],
form input[type="password"],
form input[type="url"],
form input[type="email"] {
form input[type="email"],
form input[type="search"] {
box-sizing: border-box;
width: 100%;
font-family: inherit;
@@ -363,9 +364,15 @@ a {
color: var(--green);
}
#buffer-list li a, a.timestamp, a.nick {
color: var(--gray);
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,
a.timestamp:hover, a.timestamp:active,
a.nick:hover, a.nick:active {
@@ -387,6 +394,7 @@ details summary[role="button"] {
}
#buffer .logline {
white-space: pre-wrap;
overflow: auto;
}
#buffer .talk, #buffer .motd {
color: var(--main-color);
@@ -561,21 +569,44 @@ kbd {
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) {
html {
scrollbar-color: var(--gray) transparent;
}
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-list li.unread-message a {
color: var(--green);
}
#buffer-list li.unread-highlight a {
color: #0062cc;
}
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-header .status-gone {
color: #fb885b;
@@ -588,7 +619,8 @@ kbd {
form input[type="username"],
form input[type="password"],
form input[type="url"],
form input[type="email"] {
form input[type="email"],
form input[type="search"] {
color: #ffffff;
background: var(--sidebar-background);
border: 1px solid #495057;
@@ -598,16 +630,12 @@ kbd {
form input[type="username"]:focus,
form input[type="password"]:focus,
form input[type="url"]:focus,
form input[type="email"]:focus {
form input[type="email"]:focus,
form input[type="search"]:focus {
outline: 0;
border-color: #3897ff;
}
#buffer-list li a,
a.nick {
color: var(--main-color);
}
#buffer {
background: var(--main-background);
}
@@ -677,6 +705,10 @@ kbd {
border: 1px solid 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) {