119 Commits

Author SHA1 Message Date
Simon Ser
b11f58b975 state: fix prefix() call in MODE handler
Lost during a refactoring.

Fixes: ab3d4dd661 ("Refactor ISUPPORT handling")
2021-12-16 23:37:33 +01:00
Simon Ser
4704e0f12f ci: fix deploy skip 2021-12-16 23:14:10 +01:00
Rafael Castillo
43f1329fb0 Add away command 2021-12-13 17:35:41 +01:00
Simon Ser
4cabae89ff lib/irc: add CapRegistry 2021-12-10 15:34:51 +01:00
Simon Ser
f6895fed32 Add reconnect button 2021-12-07 13:39:02 +01:00
Simon Ser
fc93a8cef5 state: fix server bouncerNetID
Ooops.
2021-12-07 13:37:14 +01:00
Simon Ser
f3d38859d3 Move isBouncer props to server state
Avoids having to pass this around.
2021-12-07 13:16:07 +01:00
Simon Ser
f81c564d23 Implement exponential backoff for reconnections
Closes: https://todo.sr.ht/~emersion/gamja/118
2021-12-07 13:05:42 +01:00
Simon Ser
ab3d4dd661 Refactor ISUPPORT handling
Add a helper class to parse ISUPPORT tokens. Instead of having
manual ISUPPORT handling all over the place, use pre-processed
values.
2021-12-07 12:09:10 +01:00
Simon Ser
31b293fa03 lib/client: use Error objects for error events 2021-12-06 23:09:30 +01:00
Simon Ser
f9ec578fce Handle FAIL ACCOUNT_REQUIRED 2021-12-06 22:54:15 +01:00
Simon Ser
305f510501 Read nickname from RPL_WELCOME
References: https://github.com/ircdocs/modern-irc/pull/146
2021-12-06 17:55:47 +01:00
Simon Ser
05f7c6e9fe Add Client.join, show join errors in popup 2021-12-04 17:44:23 +01:00
Simon Ser
fc8aa30756 lib/client: add generic error handling to roundtrip() 2021-12-04 17:22:36 +01:00
Simon Ser
8c8bd43638 lib/client: introduce IRCError 2021-12-04 17:05:34 +01:00
Simon Ser
30e3ec392f Update channel join status when kicked 2021-12-04 16:52:38 +01:00
Simon Ser
ada9ff3b71 components/buffer-header: fix missing "join" button for parted channel 2021-12-03 19:09:52 +01:00
Simon Ser
93ba0e6443 Disable debug logs in production
console.debug logs cause some performance issues because the browser
is forced to save the logged objects just in case the user opens the
debugging tools.

They can be force-enabled back by adding ?debug=1 to the URL.

Only console.debug is disabled, console.log and other levels are a lot
less verbose and still enabled by default.
2021-12-01 11:40:59 +01:00
Simon Ser
07c9cdebb6 Add usage message to development server 2021-12-01 10:44:03 +01:00
Simon Ser
aef2812348 Add custom developement server
This implements a tiny WebSocket proxy useful for development
purposes.
2021-12-01 10:34:41 +01:00
Simon Ser
a1ff1be342 Mark auth dialog as loading 2021-11-30 16:05:08 +01:00
Simon Ser
47f56f06b9 Mark register/verify dialogs as loading 2021-11-30 15:49:52 +01:00
Simon Ser
1e84412172 Show "Manage network" even if upstream is disconnected
Fixes: 86853eb2e5 ("components/buffer-header: hide action buttons when disconnected")
2021-11-30 15:29:24 +01:00
Simon Ser
451bb4c73f Add link to verify account next to VERIFICATION_REQUIRED message 2021-11-30 15:13:34 +01:00
Simon Ser
be08302c1f Add support for draft/account-registration
A new UI to register and verify accounts is added.
2021-11-30 14:59:44 +01:00
Drew DeVault
b1d5f1436e Improve noscript UI appearance 2021-11-29 13:53:23 +01:00
Simon Ser
c4a78283af Linkify error messages
Sometimes servers will put links and channels in their error
messages. Make it easy for users to click them.
2021-11-29 13:38:07 +01:00
Simon Ser
25e69a551e Clear channel joined field when disconnected 2021-11-29 11:44:45 +01:00
Simon Ser
86853eb2e5 components/buffer-header: hide action buttons when disconnected 2021-11-29 11:44:28 +01:00
Simon Ser
1800b6bea1 components/member-list: re-render on State.users update 2021-11-28 20:13:08 +01:00
Simon Ser
fcce340846 Dim away users in member list
References: https://todo.sr.ht/~emersion/gamja/13
2021-11-28 20:09:48 +01:00
Simon Ser
e29ccf7220 Add embedded Content-Security-Policy
Add a baseline CSP applicable to all gamja deployments. Resources
can only be loaded from the current host, frames and objects are
disallowed, and scripts are allowed to connect to any host (to allow
cross-site WebSocket connections).

If the server returns a different CSP via an HTTP header, the
effective CSP will be the intersection.
2021-11-27 12:35:02 +01:00
Simon Ser
d8d2cbe0f7 readme: add nginx file server directive 2021-11-27 12:26:25 +01:00
Simon Ser
0d2067e33e components/connect-form: replace auto-join text field with checkbox
The intent of the auto-join field is to ask the user whether they
really want to join the pre-filled channel. Users rarely want to
customize this field, they can just manually click "Join" after
connecting if they want to join another channel.
2021-11-27 12:08:23 +01:00
Simon Ser
3e309e9dfe Ignore RPL_AWAY 2021-11-23 17:58:49 +01:00
Simon Ser
3e2ac307f6 Add post-connect UI to login via SASL
If the server supports SASL and if we aren't logged in with any
account, add a UI to authenticate via SASL. This allows users to
login anonymously then login via SASL.

This will also ease the draft/account-registration implementation.
2021-11-21 16:40:46 +01:00
Simon Ser
24b50a332c lib/client: make authenticate() return a promise
This lets the caller handle the success/failure.
2021-11-21 16:06:13 +01:00
Simon Ser
adefc620de lib/client: send BOUNCER BIND and CAP END immediately
Don't wait for auth to finish. This reduces the number of roundtrips.
2021-11-21 13:48:41 +01:00
Simon Ser
bc3abbec32 lib/client: catch handleMessage errors 2021-11-21 13:48:07 +01:00
Simon Ser
4f927b5536 lib/client: always request sasl cap when available
This will allow us to issue post-registration SASL commands.
2021-11-21 13:35:32 +01:00
Simon Ser
86b08296a0 lib/client: don't disconnect on SASL error if registered
This will let users try multiple auth attempts when we'll implement
post-registration auth.
2021-11-21 13:23:14 +01:00
Simon Ser
25dd6aabf6 lib/client: remove one roundtrip during SASL auth
Instead of waiting for the server's empty challenge, send two
AUTHENTICATE commands in a row.
2021-11-21 13:21:42 +01:00
Simon Ser
0af40a1a8e state: add account to server 2021-11-21 12:13:44 +01:00
Simon Ser
51bf8da3d6 lib/client: don't error out if SASL isn't available on RPL_WELCOME
Some servers (soju) might remove the sasl cap on connection
registration.
2021-11-19 19:32:13 +01:00
Cara Salter
723951a07b commands: Add LIST command
Signed-off-by: Cara Salter <cara@devcara.com>
2021-11-18 16:24:18 +01:00
Simon Ser
c4c0a77162 Avoid inline script in index.html
This helps Parcel generate a proper standalone JS bundle.
2021-11-17 10:58:02 +01:00
Simon Ser
3f2553291f ci: fix deploy branch check again, exclude config.json 2021-11-17 10:45:18 +01:00
Simon Ser
debd50f482 ci: fix deploy branch check 2021-11-17 10:33:16 +01:00
Simon Ser
a57428002f ci: add deploy task 2021-11-17 10:31:09 +01:00
Simon Ser
bbfeb5bcbc ci: add .build.yml 2021-11-17 10:20:49 +01:00
Simon Ser
0980983bdc readme: add link to IRC channel 2021-11-17 10:17:41 +01:00
Simon Ser
e37c2a2cec Auto-dismiss client error on reconnect
References: https://todo.sr.ht/~emersion/gamja/74
2021-11-17 10:12:36 +01:00
Simon Ser
82e5a2795d Properly handle port in irc:// URLs 2021-11-16 11:52:38 +01:00
Simon Ser
a0b250df3f Reword ProtocolHandlerNagger message 2021-11-16 11:30:33 +01:00
Simon Ser
321140327e Add UI to enable protocol handler 2021-11-16 11:19:25 +01:00
Simon Ser
be475026c8 lib/irc: fix handling for prefixes without host
name!user is a valid prefix.
2021-11-15 16:05:51 +01:00
Simon Ser
55361c5a2b Store WHO list in RPL_ENDOFWHO
This allows the state-tracker to figure out whether a WHO query
returned no result.
2021-11-10 10:32:23 +01:00
Simon Ser
c11bf6508a Only allow one WHO command at a time
Closes: https://todo.sr.ht/~emersion/gamja/120
2021-11-10 10:08:47 +01:00
Simon Ser
195e4ca371 Don't stop fetching backlog on error
Some servers allow fetching history from some targets but not
others. Don't completely stop fetching chat history on error.

The root cause was a variable shadowing in Client.fetchBatch.
2021-11-10 09:53:17 +01:00
Simon Ser
1206cfae37 Add support for draft/extended-monitor
References: https://github.com/ircv3/ircv3-specifications/pull/466
2021-11-09 12:50:11 +01:00
Simon Ser
df29650b98 Always insert non-chathistory messages at the end 2021-11-09 10:49:18 +01:00
Simon Ser
94901f1662 Request WHO info w/ empty message list in switchBuffer 2021-11-08 15:03:05 +01:00
Simon Ser
9475ffb8c6 Don't auto-join without prompting user 2021-11-08 13:01:54 +01:00
Simon Ser
f3c48a3748 Add "open" URL param
This can be set to an irc:// URL to open. This is useful for
bouncers.
2021-11-08 12:33:02 +01:00
Simon Ser
14031c594b Ask confirmation before JOIN on irc:// link click 2021-11-08 10:44:10 +01:00
Simon Ser
74fe6ee944 Auto-join when adding new network on irc:// link click
Closes: https://todo.sr.ht/~emersion/gamja/111
2021-11-07 19:47:49 +01:00
Simon Ser
a58befd6d7 s/var/let/ 2021-11-07 13:51:39 +01:00
Simon Ser
38a3075a2c Disconnect previous server on connect re-submit 2021-11-07 13:50:26 +01:00
Simon Ser
96dd8476ad De-duplicate nicks in folded JOIN/PART/QUIT lines 2021-11-05 15:00:08 +01:00
Simon Ser
800f5ceb6a Keep track of channel join status
This makes us behave better when we receive a self-PART message
from the server.
2021-11-05 11:49:56 +01:00
Simon Ser
7b19cf48a4 Add Parcel to dev dependencies
Closes: https://todo.sr.ht/~emersion/gamja/119
2021-11-04 12:21:21 +01:00
Simon Ser
50f10a43dd components/buffer: show MODE target if different from buffer name
This happens for user modes, for instance.
2021-11-03 21:58:26 +01:00
Simon Ser
eb66045371 lib/client: use Client.isMyNick to handle self-NICK messages
This handles case-mapping.
2021-11-03 21:50:33 +01:00
Simon Ser
a1ab87c71c Route self-NICK messages to server buffer 2021-11-03 21:49:53 +01:00
Simon Ser
8ebb61cb0e Route user MODE messages to server buffer 2021-11-03 21:44:24 +01:00
Simon Ser
8f90613951 components/buffer-header: add help text for user details
This makes it easier for users new to IRC to figure out what these
things mean. Additionally, it's not possible for a malicious user
to spoof the <abbr> style.
2021-11-03 17:23:32 +01:00
Simon Ser
0888af4a6f Request more messages for event-playback infinite scrolling
When the server supports draft/event-playback, some messages (like
join/part/etc) may be collapsed together. Request more messages to
avoid ending up with a half-filled page.
2021-11-03 16:31:12 +01:00
Simon Ser
08cd94d775 lib/irc: add "fullname" to isMeaningfulRealname 2021-11-02 18:12:18 +01:00
Simon Ser
eec4126562 components/buffer-header: mark unauthenticated users as such 2021-11-02 18:04:53 +01:00
Simon Ser
6acf6d544a components/buffer-header: skip account name if it matches nick 2021-11-02 18:01:07 +01:00
Simon Ser
ac7785aa7f lib/client: fix missing account in WHOX 2021-11-02 17:58:00 +01:00
Simon Ser
85e73d0ee8 Add RPL_WELCOME to server buffer 2021-11-02 15:27:24 +01:00
Simon Ser
483f0c65b1 Add hint in server operators buffer header 2021-11-01 18:45:16 +01:00
Simon Ser
33c3cf3278 Remove unnecessary irc.formatDate call 2021-10-29 16:34:50 +02:00
Simon Ser
40210f8b00 Upgrade http-server 2021-10-29 16:25:58 +02:00
Simon Ser
a1057092e0 state: move in QUIT and NICK update logic 2021-10-23 23:24:11 +02:00
Simon Ser
bf471abb1b Add App.routeMessage
This splits handleMessage into two functions: one decides in which
buffers the message should be appended to, the other performs
message side-effects like auto-join.
2021-10-23 23:01:32 +02:00
Simon Ser
c4a1f38b33 state: process RPL_NAMREPLY atomically
This allows updating the buf.members map only once when receiving
RPL_ENDOFNAMES, instead of repeatedly re-creating it each time a
RPL_NAMREPLY message is received.
2021-10-23 20:05:07 +02:00
Simon Ser
92043ded2c lib/client: generalize pendingWHOIS, store list in ENDOF* messages
This allows processing a list of replies atomically and receiving
the ENDOF* marker.
2021-10-23 20:03:57 +02:00
Simon Ser
b059e034e2 lib/client: rename whoisDB to pendingWHOIS, garbage collect 2021-10-23 19:48:04 +02:00
Simon Ser
49a59077b7 lib/irc: extend parseURL to support flags and skip auth + options 2021-10-20 14:33:16 +02:00
Simon Ser
a313363ed7 gitignore: add Parcel files 2021-10-20 10:55:49 +02:00
Simon Ser
ab2f8092a8 Add minimal Parcel integration
Closes: https://todo.sr.ht/~emersion/gamja/107
2021-10-19 00:50:02 +02:00
Simon Ser
4309cf44d3 Avoid using export * as namespace
This isn't supported by Safari.
2021-10-18 23:59:18 +02:00
Simon Ser
2d032259db Pretty-print RPL_LOGGEDIN and RPL_LOGGEDOUT 2021-10-18 22:11:14 +02:00
Simon Ser
3d09c43a91 Don't add RPL_YOURHOST to server buffer 2021-10-18 22:08:21 +02:00
Simon Ser
e7054eab13 Don't add RPL_SASLSUCCESS to server buffer 2021-10-18 22:05:25 +02:00
Simon Ser
d9f36c82ba Allow bouncers to set NETWORK in ISUPPORT
This allows bouncers to customize the name they appear with.
2021-10-18 19:51:30 +02:00
Simon Ser
12440691c9 Unescape ISUPPORT values
This allows ISUPPORT values to contain spaces.

References: https://github.com/ircdocs/modern-irc/pull/137
2021-10-18 13:29:11 +02:00
Simon Ser
34aea84dde Close buffer tabs on middle click 2021-10-17 19:33:02 +02:00
Simon Ser
a31976586c Fallback to bouncer network host if name is unset 2021-10-15 18:23:56 +02:00
Simon Ser
8bdde589bb lib/irc: "unknown" is not a meaningful realname 2021-10-15 17:44:33 +02:00
Simon Ser
bfef13824e Use ISUPPORT NETWORK if user hasn't specified custom name 2021-10-15 14:05:39 +02:00
Simon Ser
1a8d539c9e Use linkifyjs module 2021-10-14 20:55:55 +02:00
Simon Ser
a120d79585 Handle IRC URLs without channel name 2021-10-13 16:47:01 +02:00
Simon Ser
3562478946 Open dialog to create new network on IRC URL click
If we're running under a bouncer and the user clicks a link with
a server we aren't connected to yet, open the dialog to add a new
network.

References: https://todo.sr.ht/~emersion/gamja/71
2021-10-13 16:40:34 +02:00
Simon Ser
405bc51c26 Handle click on irc:// channel URLs inside buffers
References: https://todo.sr.ht/~emersion/gamja/71
2021-10-13 16:18:59 +02:00
Simon Ser
631f119061 Switch from anchorme to linkifyjs 2021-10-13 15:33:41 +02:00
Simon Ser
a7d3a3940a readme: mention server.ping when server doesn't send PINGs
For instance, soju doesn't send PINGs.
2021-10-12 20:18:29 +02:00
Simon Ser
21a4a71542 Add support for SASL EXTERNAL
Can be useful when the server is using e.g. a cookie for
authentication purposes.
2021-10-12 17:29:56 +02:00
Simon Ser
a890665775 Allow revealing server field with ?server 2021-10-09 13:33:01 +02:00
Simon Ser
a920914b4c Add nick to config.json 2021-10-09 10:45:44 +02:00
Simon Ser
47b12cc5d9 Add autoconnect to config.json 2021-10-09 10:45:44 +02:00
Simon Ser
312a3f812e Don't allow overriding server URL if set in config.json
This has security implications.
2021-10-09 10:34:51 +02:00
Simon Ser
e3e3315125 Inherit from default connectParams in handleConfig
When handleConfig is called, this.state.connectParams will be set
to its default value. Inherit from it so that autoconnect isn't
missing any. If we ever add a new connect parameter, we don't want
an old localStorage to break connect() because it's missing the
param.
2021-10-09 10:10:51 +02:00
Simon Ser
d2ac1e152a Add more type checks for config.json 2021-10-09 09:57:54 +02:00
Simon Ser
8cc61bf577 lib/client: handle MONITOR without value in ISUPPORT 2021-10-07 21:49:54 +02:00
Simon Ser
4577f0a27f components/buffer: pretty-print RPL_CHANNELMODEIS and RPL_CREATIONTIME 2021-10-06 12:12:49 +02:00
Simon Ser
19ee5553f6 components/buffer: add RPL_UMODEIS pretty-printing 2021-10-05 11:22:20 +02:00
27 changed files with 16176 additions and 862 deletions

19
.build.yml Normal file
View File

@@ -0,0 +1,19 @@
image: alpine/latest
packages:
- npm
- rsync
sources:
- https://git.sr.ht/~emersion/gamja
secrets:
- 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
tasks:
- setup: |
cd gamja
npm install --include=dev
npm run build
- 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@emersion.fr:/srv/http/gamja

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
node_modules
/config.json
.parcel-cache
/dist

View File

@@ -12,6 +12,9 @@ First install dependencies:
npm install --production
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`.
@@ -37,6 +40,10 @@ 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;
@@ -48,19 +55,26 @@ location /socket {
}
```
If you are unable to configure the proxy timeout accordingly, you can set the
`server.ping` option in `config.json` to an interval, in seconds, between which
gamja will send opportunistic pings.
If you are unable to configure the proxy timeout accordingly, or if your IRC
server doesn't send PINGs, you can set the `server.ping` option in
`config.json` (see below).
### Development server
Start your IRC WebSocket server, e.g. on port 8080. Then run:
If you don't have an IRC WebSocket server at hand, gamja's development server
can be used. For instance, to run gamja on Libera Chat:
npm install
npm start
npm install --include=dev
npm start -- irc.libera.chat
This will start a development HTTP server for gamja. Connect to it and append
`?server=ws://localhost:8080` to the URL.
See `npm start -- -h` for a list of options.
### Production build
Optionally, [Parcel] can be used to build a minified version of gamja.
npm install --include=dev
npm run build
## Query parameters
@@ -69,6 +83,8 @@ 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).
@@ -87,9 +103,14 @@ gamja default settings can be set using a `config.json` file at the root:
"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, and "disabled" to never ask for a password. Defaults to
// "optional".
// require it, "disabled" to never ask for a password, or "external" to
// use SASL EXTERNAL. Defaults to "optional".
"auth": "optional",
// Default nickname (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.
@@ -101,7 +122,7 @@ gamja default settings can be set using a `config.json` file at the root:
## Contributing
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
in #soju on Libera Chat.
in [#soju on Libera Chat].
## License
@@ -114,3 +135,6 @@ Copyright (C) 2020 The gamja Contributors
[webircgateway]: https://github.com/kiwiirc/webircgateway
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
[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
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju

View File

@@ -93,6 +93,17 @@ function givemode(app, args, mode) {
}
export default {
"away": {
usage: "[message]",
description: "Set away message",
execute: (app, args) => {
const params = []
if (args.length) {
params.push(args.join(" "));
}
getActiveClient(app).send({command: "AWAY", params});
},
},
"ban": {
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
@@ -322,10 +333,11 @@ export default {
execute: (app, args) => {
let newRealname = args.join(" ");
let client = getActiveClient(app);
if (!client.enabledCaps["setname"]) {
if (!client.caps.enabled.has("setname")) {
throw new Error("Server doesn't support changing the realname");
}
client.send({ command: "SETNAME", params: [newRealname] });
// TODO: save to local storage
},
},
"stats": {
@@ -411,4 +423,12 @@ export default {
markServerBufferUnread(app);
},
},
"list": {
usage: "[filter]",
description: "Retrieve a list of channels from a network",
execute: (app, args) => {
getActiveClient(app).send({ command: "LIST", params: args });
markServerBufferUnread(app);
},
},
};

File diff suppressed because it is too large Load Diff

51
components/auth-form.js Normal file
View File

@@ -0,0 +1,51 @@
import { html, Component } from "../lib/index.js";
export default class NetworkForm extends Component {
state = {
username: "",
password: "",
};
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
if (props.username) {
this.state.username = props.username;
}
}
handleChange(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.state.username, this.state.password);
}
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label>
Username:<br/>
<input type="username" name="username" value=${this.state.username} required/>
</label>
<br/><br/>
<label>
Password:<br/>
<input type="password" name="password" value=${this.state.password} required autofocus/>
</label>
<br/><br/>
<button>Login</button>
</form>
`;
}
}

View File

@@ -21,24 +21,12 @@ function NickStatus(props) {
}
export default function BufferHeader(props) {
function handleCloseClick(event) {
event.preventDefault();
props.onClose();
}
function handleJoinClick(event) {
event.preventDefault();
props.onJoin();
}
function handleAddNetworkClick(event) {
event.preventDefault();
props.onAddNetwork();
}
function handleManageNetworkClick(event) {
event.preventDefault();
props.onManageNetwork();
let fullyConnected = props.server.status === ServerStatus.REGISTERED;
if (props.bouncerNetwork) {
fullyConnected = fullyConnected && props.bouncerNetwork.state === "connected";
}
let description = null, actions = null;
let description = null, actions = [];
switch (props.buffer.type) {
case BufferType.SERVER:
switch (props.server.status) {
@@ -74,56 +62,95 @@ export default function BufferHeader(props) {
break;
}
if (props.isBouncer) {
if (props.server.isupport.get("BOUNCER_NETID")) {
actions = html`
<button
key="join"
onClick=${handleJoinClick}
>Join channel</button>
<button
key="manage"
onClick=${handleManageNetworkClick}
>Manage network</button>
`;
let joinButton = html`
<button
key="join"
onClick=${props.onJoin}
>Join channel</button>
`;
let reconnectButton = html`
<button
key="reconect"
onClick=${props.onReconnect}
>Reconnect</button>
`;
if (props.server.isBouncer) {
if (props.server.bouncerNetID) {
if (fullyConnected) {
actions.push(joinButton);
}
if (props.server.status === ServerStatus.REGISTERED) {
actions.push(html`
<button
key="manage"
onClick=${props.onManageNetwork}
>Manage network</button>
`);
}
} else {
actions = html`
<button
key="add"
onClick=${handleAddNetworkClick}
>Add network</button>
if (fullyConnected) {
actions.push(html`
<button
key="add"
onClick=${props.onAddNetwork}
>Add network</button>
`);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${handleCloseClick}
onClick=${props.onClose}
>Disconnect</button>
`;
`);
}
} else {
actions = html`
<button
key="join"
onClick=${handleJoinClick}
>Join channel</button>
if (fullyConnected) {
actions.push(joinButton);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${handleCloseClick}
onClick=${props.onClose}
>Disconnect</button>
`;
`);
}
break;
case BufferType.CHANNEL:
if (props.buffer.topic) {
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
}
actions = html`
<button
key="part"
class="danger"
onClick=${handleCloseClick}
>Leave</button>
`;
if (props.buffer.joined) {
actions.push(html`
<button
key="part"
class="danger"
onClick=${props.onClose}
>Leave</button>
`);
} else {
if (fullyConnected) {
actions.push(html`
<button
key="join"
onClick=${props.onJoin}
>Join</button>
`);
}
actions.push(html`
<button
key="part"
class="danger"
onClick=${props.onClose}
>Close</button>
`);
}
break;
case BufferType.NICK:
if (props.user) {
@@ -144,9 +171,33 @@ export default function BufferHeader(props) {
details.push(`${props.user.username}@${props.user.hostname}`);
}
if (props.user.account) {
details.push(`authenticated as ${props.user.account}`);
let desc = `This user is verified and has logged in to the server with the account ${props.user.account}.`;
let item;
if (props.user.account === props.buffer.name) {
item = "authenticated";
} else {
item = `authenticated as ${props.user.account}`;
}
details.push(html`<abbr title=${desc}>${item}</abbr>`);
} else if (props.server.reliableUserAccounts) {
// If the server supports MONITOR and WHOX, we can faithfully
// keep user.account up-to-date for user queries
let desc = "This user has not been verified and is not logged in.";
details.push(html`<abbr title=${desc}>unauthenticated</abbr>`);
}
if (props.user.operator) {
let desc = "This user is a server operator, they have administrator privileges.";
details.push(html`<abbr title=${desc}>server operator</abbr>`);
}
details = details.map((item, i) => {
if (i === 0) {
return item;
}
return [", ", item];
});
if (details.length > 0) {
details = ["(", details, ")"];
}
details = details.length > 0 ? `(${details.join(", ")})` : null;
description = html`<${NickStatus} status=${status}/> ${realname} ${details}`;
}
@@ -155,7 +206,7 @@ export default function BufferHeader(props) {
<button
key="close"
class="danger"
onClick=${handleCloseClick}
onClick=${props.onClose}
>Close</button>
`;
break;
@@ -163,7 +214,7 @@ export default function BufferHeader(props) {
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
name = getServerName(props.server, props.bouncerNetwork);
}
return html`

View File

@@ -7,10 +7,16 @@ function BufferItem(props) {
event.preventDefault();
props.onClick();
}
function handleMouseDown(event) {
if (event.button === 1) { // middle click
event.preventDefault();
props.onClose();
}
}
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
name = getServerName(props.server, props.bouncerNetwork);
}
let classes = ["type-" + props.buffer.type];
@@ -23,7 +29,11 @@ function BufferItem(props) {
return html`
<li class="${classes.join(" ")}">
<a href=${getBufferURL(props.buffer)} onClick=${handleClick}>${name}</a>
<a
href=${getBufferURL(props.buffer)}
onClick=${handleClick}
onMouseDown=${handleMouseDown}
>${name}</a>
</li>
`;
}
@@ -34,7 +44,7 @@ export default function BufferList(props) {
let server = props.servers.get(buf.server);
let bouncerNetwork = null;
let bouncerNetID = server.isupport.get("BOUNCER_NETID");
let bouncerNetID = server.bouncerNetID;
if (bouncerNetID) {
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
}
@@ -44,9 +54,9 @@ export default function BufferList(props) {
key=${buf.id}
buffer=${buf}
server=${server}
isBouncer=${props.isBouncer}
bouncerNetwork=${bouncerNetwork}
onClick=${() => props.onBufferClick(buf)}
onClose=${() => props.onBufferClose(buf)}
active=${props.activeBuffer == buf.id}
/>
`;

View File

@@ -2,7 +2,8 @@ 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, getNickURL, getChannelURL, getMessageURL } from "../state.js";
import { BufferType, ServerStatus, getNickURL, getChannelURL, getMessageURL } from "../state.js";
import * as store from "../store.js";
import Membership from "./membership.js";
function djb2(s) {
@@ -76,6 +77,8 @@ class LogLine extends Component {
let onNickClick = this.props.onNickClick;
let onChannelClick = this.props.onChannelClick;
let onVerifyClick = this.props.onVerifyClick;
function createNick(nick) {
return html`
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
@@ -95,11 +98,11 @@ class LogLine extends Component {
let lineClass = "";
let content;
let invitee;
let invitee, target, account;
switch (msg.command) {
case "NOTICE":
case "PRIVMSG":
let target = msg.params[0];
target = msg.params[0];
let text = msg.params[1];
let ctcp = irc.parseCTCP(msg);
@@ -122,7 +125,7 @@ class LogLine extends Component {
}
let status = null;
let allowedPrefixes = server.isupport.get("STATUSMSG");
let allowedPrefixes = server.statusMsg;
if (target !== buf.name && allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
if (parts.name === buf.name) {
@@ -161,9 +164,14 @@ class LogLine extends Component {
`;
break;
case "MODE":
target = msg.params[0];
content = html`
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
`;
// TODO: case-mapping
if (buf.name !== target) {
content = html`${content} on ${target}`;
}
break;
case "TOPIC":
let topic = msg.params[1];
@@ -186,6 +194,10 @@ class LogLine extends Component {
`;
}
break;
case irc.RPL_WELCOME:
let nick = msg.params[0];
content = html`Connected to server, your nickname is ${nick}`;
break;
case irc.RPL_INVITING:
invitee = msg.params[1];
content = html`${createNick(invitee)} has been invited to the channel`;
@@ -194,11 +206,59 @@ class LogLine extends Component {
lineClass = "motd";
content = linkify(stripANSI(msg.params[1]), onChannelClick);
break;
case irc.RPL_LOGGEDIN:
account = msg.params[2];
content = html`You are now authenticated as ${account}`;
break;
case irc.RPL_LOGGEDOUT:
content = html`You are now unauthenticated`;
break;
case "REGISTER":
account = msg.params[1];
let reason = msg.params[2];
function handleVerifyClick(event) {
event.preventDefault();
onVerifyClick(account, reason);
}
switch (msg.params[0]) {
case "SUCCESS":
content = html`A new account has been created, you are now authenticated as ${account}`;
break;
case "VERIFICATION_REQUIRED":
content = html`A new account has been created, but you need to <a href="#" onClick=${handleVerifyClick}>verify it</a>: ${linkify(reason)}`;
break;
}
break;
case "VERIFY":
account = msg.params[1];
content = html`The new account has been verified, you are now authenticated as ${account}`;
break;
case irc.RPL_UMODEIS:
let mode = msg.params[1];
if (mode) {
content = html`Your user mode is ${mode}`;
} else {
content = html`You have no user mode`;
}
break;
case irc.RPL_CHANNELMODEIS:
content = html`Channel mode is ${msg.params.slice(2).join(" ")}`;
break;
case irc.RPL_CREATIONTIME:
let date = new Date(parseInt(msg.params[2], 10) * 1000);
content = html`Channel was created on ${date.toLocaleString()}`;
break;
default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
lineClass = "error";
}
content = html`${msg.command} ${msg.params.join(" ")}`;
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
}
if (!content) {
return null;
}
return html`
@@ -266,7 +326,9 @@ class FoldGroup extends Component {
return;
}
let plural = byCommand[cmd].length > 1;
let nicks = new Set(byCommand[cmd].map((msg) => msg.prefix.name));
let plural = nicks.size > 1;
let action;
switch (cmd) {
case "JOIN":
@@ -286,9 +348,7 @@ class FoldGroup extends Component {
content.push(", ");
}
let nicks = byCommand[cmd].map((msg) => msg.prefix.name);
content.push(createNickList(nicks, createNick));
content.push(createNickList([...nicks], createNick));
content.push(" " + action);
});
@@ -384,6 +444,82 @@ class NotificationNagger extends Component {
}
}
class ProtocolHandlerNagger extends Component {
state = { nag: true };
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.state.nag = !store.naggedProtocolHandler.load();
}
handleClick(event) {
event.preventDefault();
let url = window.location.origin + window.location.pathname + "?open=%s";
try {
navigator.registerProtocolHandler("irc", url);
navigator.registerProtocolHandler("ircs", url);
} catch (err) {
console.error("Failed to register protocol handler: ", err);
}
store.naggedProtocolHandler.put(true);
this.setState({ nag: false });
}
render() {
if (!navigator.registerProtocolHandler || !this.state.nag) {
return null;
}
let name = this.props.bouncerName || "this bouncer";
return html`
<div class="logline">
<${Timestamp}/>
${" "}
<a href="#" onClick=${this.handleClick}>Register our protocol handler</a> to open IRC links with ${name}
</div>
`;
}
}
function AccountNagger({ server, onAuthClick, onRegisterClick }) {
let accDesc = "an account on this server";
if (server.name) {
accDesc = "a " + server.name + " account";
}
function handleAuthClick(event) {
event.preventDefault();
onAuthClick();
}
function handleRegisterClick(event) {
event.preventDefault();
onRegisterClick();
}
let msg = [html`
You are unauthenticated on this server,
${" "}
<a href="#" onClick=${handleAuthClick}>login</a>
${" "}
`];
if (server.supportsAccountRegistration) {
msg.push(html`or <a href="#" onClick=${handleRegisterClick}>register</a> ${accDesc}`);
} else {
msg.push(html`if you have ${accDesc}`);
}
return html`
<div class="logline">
<${Timestamp}/> ${msg}
</div>
`;
}
class DateSeparator extends Component {
constructor(props) {
super(props);
@@ -422,18 +558,35 @@ export default class Buffer extends Component {
render() {
let buf = this.props.buffer;
let server = this.props.server;
if (!buf) {
return null;
}
let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let serverName = server.name;
let children = [];
if (buf.type == BufferType.SERVER) {
children.push(html`<${NotificationNagger}/>`);
}
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) {
children.push(html`
<${AccountNagger}
server=${server}
onAuthClick=${this.props.onAuthClick}
onRegisterClick=${this.props.onRegisterClick}
/>
`);
}
let onChannelClick = this.props.onChannelClick;
let onNickClick = this.props.onNickClick;
let onVerifyClick = this.props.onVerifyClick;
function createLogLine(msg) {
return html`
<${LogLine}
@@ -443,6 +596,7 @@ export default class Buffer extends Component {
server=${server}
onChannelClick=${onChannelClick}
onNickClick=${onNickClick}
onVerifyClick=${onVerifyClick}
/>
`;
}

View File

@@ -1,4 +1,5 @@
import { html, Component, createRef } from "../lib/index.js";
import linkify from "../lib/linkify.js";
export default class ConnectForm extends Component {
state = {
@@ -9,7 +10,7 @@ export default class ConnectForm extends Component {
rememberMe: false,
username: "",
realname: "",
autojoin: "",
autojoin: true,
};
nickInput = createRef();
@@ -27,7 +28,6 @@ export default class ConnectForm extends Component {
rememberMe: props.params.autoconnect || false,
username: props.params.username || "",
realname: props.params.realname || "",
autojoin: (props.params.autojoin || []).join(","),
};
}
}
@@ -61,15 +61,13 @@ export default class ConnectForm extends Component {
username: params.username || params.nick,
password: this.state.password,
};
} else if (this.props.auth === "external") {
params.saslExternal = true;
}
this.state.autojoin.split(",").forEach(function(ch) {
ch = ch.trim();
if (!ch) {
return;
}
params.autojoin.push(ch);
});
if (this.state.autojoin) {
params.autojoin = this.props.params.autojoin || [];
}
this.props.onSubmit(params);
}
@@ -107,12 +105,12 @@ export default class ConnectForm extends Component {
`;
} else if (this.props.error) {
status = html`
<p class="error-text">${this.props.error}</p>
<p class="error-text">${linkify(this.props.error)}</p>
`;
}
let auth = null;
if (this.props.auth !== "disabled") {
if (this.props.auth !== "disabled" && this.props.auth !== "external") {
auth = html`
<label>
Password:<br/>
@@ -129,22 +127,22 @@ export default class ConnectForm extends Component {
`;
}
let autojoin = html`
<label>
Auto-join channels:<br/>
<input
type="text"
name="autojoin"
value=${this.state.autojoin}
disabled=${disabled}
placeholder="Comma-separated list of channels"
/>
</label>
<br/>
`;
// Show autojoin field in advanced options, except if it's pre-filled
let isAutojoinAdvanced = (this.props.params.autojoin || []).length === 0;
let autojoin = null;
let channels = this.props.params.autojoin || [];
if (channels.length > 0) {
let s = channels.length > 1 ? "s" : "";
autojoin = html`
<label>
<input
type="checkbox"
name="autojoin"
checked=${this.state.autojoin}
/>
Auto-join channel${s} <strong>${channels.join(', ')}</strong>
</label>
<br/><br/>
`;
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
@@ -165,7 +163,7 @@ export default class ConnectForm extends Component {
${auth}
${!isAutojoinAdvanced ? [autojoin, html`<br/>`] : null}
${autojoin}
<label>
<input
@@ -220,8 +218,6 @@ export default class ConnectForm extends Component {
/>
</label>
<br/><br/>
${isAutojoinAdvanced ? autojoin : null}
</details>
<br/>

View File

@@ -10,6 +10,10 @@ export default class JoinForm extends Component {
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
if (props.channel) {
this.state.channel = props.channel;
}
}
handleChange(event) {

View File

@@ -13,7 +13,8 @@ class MemberItem extends Component {
shouldComponentUpdate(nextProps) {
return this.props.nick !== nextProps.nick
|| this.props.membership !== nextProps.membership;
|| this.props.membership !== nextProps.membership
|| this.props.user !== nextProps.user;
}
handleClick(event) {
@@ -43,6 +44,7 @@ class MemberItem extends Component {
let title = null;
let user = this.props.user;
let classes = ["nick"];
if (user) {
let mask = "";
if (user.username && user.hostname) {
@@ -61,13 +63,18 @@ class MemberItem extends Component {
if (user.account) {
title += `\nAuthenticated as ${user.account}`;
}
if (user.away) {
classes.push("away");
title += "\nAway";
}
}
return html`
<li>
<a
href=${getNickURL(this.props.nick)}
class="nick"
class=${classes.join(" ")}
title=${title}
onClick=${this.handleClick}
>
@@ -99,7 +106,8 @@ function sortMembers(a, b) {
export default class MemberList extends Component {
shouldComponentUpdate(nextProps) {
return this.props.members !== nextProps.members;
return this.props.members !== nextProps.members
|| this.props.users !== nextProps.users;
}
render() {

View File

@@ -14,7 +14,7 @@ export default class NetworkForm extends Component {
prevParams = null;
state = {
...defaultParams,
isNew: true,
autojoin: true,
};
constructor(props) {
@@ -25,8 +25,6 @@ export default class NetworkForm extends Component {
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.state.isNew = !props.params;
if (props.params) {
Object.keys(defaultParams).forEach((k) => {
if (props.params[k] !== undefined) {
@@ -48,18 +46,22 @@ export default class NetworkForm extends Component {
let params = {};
Object.keys(defaultParams).forEach((k) => {
if (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]) {
return;
}
params[k] = this.state[k];
});
this.props.onSubmit(params);
let autojoin = this.state.autojoin ? this.props.autojoin : null;
this.props.onSubmit(params, autojoin);
}
render() {
let removeNetwork = null;
if (!this.state.isNew) {
if (!this.props.isNew) {
removeNetwork = html`
<button type="button" class="danger" onClick=${() => this.props.onRemove()}>
Remove network
@@ -67,6 +69,21 @@ export default class NetworkForm extends Component {
`;
}
let autojoin = null;
if (this.props.autojoin) {
autojoin = html`
<label>
<input
type="checkbox"
name="autojoin"
checked=${this.state.autojoin}
/>
Auto-join channel <strong>${this.props.autojoin}</strong>
</label>
<br/><br/>
`;
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label>
@@ -75,6 +92,8 @@ export default class NetworkForm extends Component {
</label>
<br/><br/>
${autojoin}
<details>
<summary role="button">Advanced options</summary>
@@ -121,7 +140,7 @@ export default class NetworkForm extends Component {
${removeNetwork}
${" "}
<button>
${this.state.isNew ? "Add network" : "Save network"}
${this.props.isNew ? "Add network" : "Save network"}
</button>
</form>
`;

View File

@@ -0,0 +1,54 @@
import { html, Component } from "../lib/index.js";
export default class RegisterForm extends Component {
state = {
email: "",
password: "",
};
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.state.email, this.state.password);
}
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label>
E-mail:<br/>
<input
type="email"
name="email"
value=${this.state.email}
required=${this.props.emailRequired}
placeholder=${this.props.emailRequired ? null : "(optional)"}
autofocus
/>
</label>
<br/><br/>
<label>
Password:<br/>
<input type="password" name="password" value=${this.state.password} required/>
</label>
<br/><br/>
<button>Register</button>
</form>
`;
}
}

45
components/verify-form.js Normal file
View File

@@ -0,0 +1,45 @@
import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js";
export default class RegisterForm extends Component {
state = {
code: "",
};
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.state.code);
}
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<p>Your account <strong>${this.props.account}</strong> has been created, but a verification code is required to complete the registration.</p>
<p>${linkify(this.props.message)}</p>
<label>
Verification code:<br/>
<input type="text" name="code" value=${this.state.code} required autofocus autocomplete="off"/>
</label>
<br/><br/>
<button>Verify account</button>
</form>
`;
}
}

78
dev-server.js Normal file
View File

@@ -0,0 +1,78 @@
import * as http from "http";
import * as tls from "tls";
import split from "split";
import { Server as StaticServer } from "node-static";
import { WebSocketServer } from "ws";
const WS_BAD_GATEWAY = 1014;
const usage = `usage: [options...] [host]
Starts an HTTP server delivering static files. If [host] is specified, the
server will proxy WebSocket connections to the specified remote IRC server.
Options:
-p <port> Listening port (default: 8080)
-h Show help message
`;
let localPort = 8080;
let remoteHost;
let remotePort = 6697;
let args = process.argv.slice(2);
while (args.length > 0 && args[0].startsWith("-")) {
switch (args[0]) {
case "-p":
localPort = parseInt(args[1], 10);
args = args.slice(2);
break;
default:
console.log(usage);
process.exit(args[0] === "-h" ? 0 : 1);
}
}
remoteHost = args[0];
let staticServer = new StaticServer(".");
let server = http.createServer((req, res) => {
staticServer.serve(req, res);
});
if (remoteHost) {
let wsServer = new WebSocketServer({ server });
wsServer.on("connection", (ws) => {
let client = tls.connect(remotePort, remoteHost, {
ALPNProtocols: ["irc"],
});
ws.on("message", (data) => {
client.write(data.toString() + "\r\n");
});
ws.on("close", () => {
client.destroy();
});
client.pipe(split()).on("data", (data) => {
ws.send(data.toString());
});
client.on("end", () => {
ws.close();
});
client.on("error", () => {
ws.close(WS_BAD_GATEWAY);
});
});
}
server.listen(localPort, "localhost");
let msg = "HTTP server listening on http://localhost:" + localPort;
if (remoteHost) {
msg += " and proxying WebSockets to " + remoteHost;
}
console.log(msg);

View File

@@ -2,20 +2,16 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'none'; object-src 'none'; connect-src *;">
<title>gamja IRC client</title>
<link rel="stylesheet" href="./style.css">
<script type="module" src="./main.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="manifest" href="manifest.json">
</head>
<body>
<noscript>
<p>Unfortunately gamja requires JavaScript. Please enable it!</p>
<p>This application requires JavaScript. Please enable it!</p>
</noscript>
<script type="module">
import { html, render } from "./lib/index.js";
import App from "./components/app.js";
render(html`<${App}/>`, document.body);
</script>
</body>
</html>

View File

@@ -13,16 +13,20 @@ const permanentCaps = [
"labeled-response",
"message-tags",
"multi-prefix",
"sasl",
"server-time",
"setname",
"draft/account-registration",
"draft/chathistory",
"draft/event-playback",
"draft/extended-monitor",
"soju.im/bouncer-networks",
];
const RECONNECT_DELAY_SEC = 10;
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
const RECONNECT_MAX_DELAY_MSEC = 10 * 60 * 1000; // 10min
// WebSocket status codes
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
@@ -46,6 +50,53 @@ const WHOX_FIELDS = {
let lastLabel = 0;
let lastWhoxToken = 0;
class IRCError extends Error {
constructor(msg) {
let text;
if (msg.params.length > 0) {
// IRC errors have a human-readable message as last param
text = msg.params[msg.params.length - 1];
} else {
text = `unknown error (${msg.command})`;
}
super(text);
this.msg = msg;
}
}
/**
* Implements a simple exponential backoff.
*/
class Backoff {
n = 0;
constructor(min, max) {
this.min = min;
this.max = max;
}
reset() {
this.n = 0;
}
next() {
if (this.n === 0) {
this.n = 1;
return this.min;
}
let dur = this.n * this.min;
if (dur > this.max) {
dur = this.max;
} else {
this.n *= 2;
}
return dur;
}
}
export default class Client extends EventTarget {
static Status = {
DISCONNECTED: "disconnected",
@@ -57,9 +108,9 @@ export default class Client extends EventTarget {
status = Client.Status.DISCONNECTED;
serverPrefix = { name: "*" };
nick = null;
availableCaps = {};
enabledCaps = {};
isupport = new Map();
supportsCap = false;
caps = new irc.CapRegistry();
isupport = new irc.Isupport();
ws = null;
params = {
@@ -69,16 +120,22 @@ export default class Client extends EventTarget {
nick: null,
pass: null,
saslPlain: null,
saslExternal: false,
bouncerNetwork: null,
};
debug = false;
batches = new Map();
autoReconnect = true;
reconnectTimeoutID = null;
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
pingIntervalID = null;
pendingHistory = Promise.resolve(null);
pendingCmds = {
WHO: Promise.resolve(null),
CHATHISTORY: Promise.resolve(null),
};
cm = irc.CaseMapping.RFC1459;
monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
whoisDB = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
pendingLists = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
whoxQueries = new Map();
constructor(params) {
@@ -102,30 +159,39 @@ export default class Client extends EventTarget {
} catch (err) {
console.error("Failed to create connection:", err);
setTimeout(() => {
this.dispatchEvent(new CustomEvent("error", { detail: "Failed to create connection: " + err }));
this.dispatchError(new Error("Failed to create connection", { cause: err }));
this.setStatus(Client.Status.DISCONNECTED);
}, 0);
return;
}
this.ws.addEventListener("open", this.handleOpen.bind(this));
this.ws.addEventListener("message", this.handleMessage.bind(this));
this.ws.addEventListener("message", (event) => {
try {
this.handleMessage(event);
} catch (err) {
this.dispatchError(err);
this.disconnect();
}
});
this.ws.addEventListener("close", (event) => {
console.log("Connection closed (code: " + event.code + ")");
if (event.code !== NORMAL_CLOSURE && event.code !== GOING_AWAY) {
this.dispatchEvent(new CustomEvent("error", { detail: "Connection error" }));
this.dispatchError(new Error("Connection error"));
}
this.ws = null;
this.setStatus(Client.Status.DISCONNECTED);
this.nick = null;
this.serverPrefix = null;
this.availableCaps = {};
this.enabledCaps = {};
this.caps = new irc.CapRegistry();
this.batches = new Map();
this.pendingHistory = Promise.resolve(null);
this.isupport = new Map();
Object.keys(this.pendingCmds).forEach((k) => {
this.pendingCmds[k] = Promise.resolve(null);
});
this.isupport = new irc.Isupport();
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
if (this.autoReconnect) {
@@ -137,11 +203,12 @@ export default class Client extends EventTarget {
};
window.addEventListener("online", handleOnline);
} else {
console.info("Reconnecting to server in " + RECONNECT_DELAY_SEC + " seconds");
let delay = this.reconnectBackoff.next();
console.info("Reconnecting to server in " + (delay / 1000) + " seconds");
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = setTimeout(() => {
this.reconnect();
}, RECONNECT_DELAY_SEC * 1000);
}, delay);
}
}
});
@@ -168,10 +235,16 @@ export default class Client extends EventTarget {
this.dispatchEvent(new CustomEvent("status"));
}
dispatchError(err) {
this.dispatchEvent(new CustomEvent("error", { detail: err }));
}
handleOpen() {
console.log("Connection opened");
this.setStatus(Client.Status.REGISTERING);
this.reconnectBackoff.reset();
this.nick = this.params.nick;
this.send({ command: "CAP", params: ["LS", "302"] });
@@ -185,6 +258,20 @@ export default class Client extends EventTarget {
});
}
pushPendingList(k, msg) {
let l = this.pendingLists.get(k);
if (!l) {
l = [];
this.pendingLists.set(k, l);
}
l.push(msg);
}
endPendingList(k, msg) {
msg.list = this.pendingLists.get(k) || [];
this.pendingLists.delete(k);
}
handleMessage(event) {
if (typeof event.data !== "string") {
console.error("Received unsupported data type:", event.data);
@@ -193,7 +280,9 @@ export default class Client extends EventTarget {
}
let msg = irc.parseMessage(event.data);
console.debug("Received:", msg);
if (this.debug) {
console.debug("Received:", msg);
}
// If the prefix is missing, assume it's coming from the server on the
// other end of the connection
@@ -210,10 +299,11 @@ export default class Client extends EventTarget {
}
let deleteBatch = null;
let k;
switch (msg.command) {
case irc.RPL_WELCOME:
if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
this.dispatchEvent(new CustomEvent("error", { detail: "Server doesn't support SASL PLAIN" }));
if (this.params.saslPlain && !this.supportsCap) {
this.dispatchError(new Error("Server doesn't support SASL PLAIN"));
this.disconnect();
return;
}
@@ -221,34 +311,42 @@ export default class Client extends EventTarget {
if (msg.prefix) {
this.serverPrefix = msg.prefix;
}
this.nick = msg.params[0];
console.log("Registration complete");
this.setStatus(Client.Status.REGISTERED);
break;
case irc.RPL_ISUPPORT:
let prevMaxMonitorTargets = this.isupport.monitor();
let tokens = msg.params.slice(1, -1);
let changed = irc.parseISUPPORT(tokens, this.isupport);
if (changed.indexOf("CASEMAPPING") >= 0) {
this.setCaseMapping(this.isupport.get("CASEMAPPING"));
}
if (changed.indexOf("MONITOR") >= 0 && this.isupport.has("MONITOR") && this.monitored.size > 0) {
let targets = Array.from(this.monitored.keys()).slice(0, this.maxMonitorTargets());
this.isupport.parse(tokens);
this.updateCaseMapping();
let maxMonitorTargets = this.isupport.monitor();
if (prevMaxMonitorTargets === 0 && this.monitored.size > 0 && maxMonitorTargets > 0) {
let targets = Array.from(this.monitored.keys()).slice(0, maxMonitorTargets);
this.send({ command: "MONITOR", params: ["+", targets.join(",")] });
}
break;
case irc.RPL_ENDOFMOTD:
case irc.ERR_NOMOTD:
// These messages are used to indicate the end of the ISUPPORT list
if (!this.isupport.has("CASEMAPPING")) {
if (!this.isupport.raw.has("CASEMAPPING")) {
// Server didn't send any CASEMAPPING token, assume RFC 1459
this.setCaseMapping("rfc1459");
this.updateCaseMapping();
}
break;
case "CAP":
this.handleCap(msg);
break;
case "AUTHENTICATE":
this.handleAuthenticate(msg);
// Both PLAIN and EXTERNAL expect an empty challenge
let challengeStr = msg.params[0];
if (challengeStr != "+") {
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
this.send({ command: "AUTHENTICATE", params: ["*"] });
}
break;
case irc.RPL_LOGGEDIN:
console.log("Logged in");
@@ -256,41 +354,35 @@ export default class Client extends EventTarget {
case irc.RPL_LOGGEDOUT:
console.log("Logged out");
break;
case irc.RPL_SASLSUCCESS:
console.log("SASL authentication success");
if (this.status != Client.Status.REGISTERED) {
if (this.enabledCaps["soju.im/bouncer-networks"] && this.params.bouncerNetwork) {
this.send({ command: "BOUNCER", params: ["BIND", this.params.bouncerNetwork] });
}
this.send({ command: "CAP", params: ["END"] });
}
case irc.RPL_NAMREPLY:
this.pushPendingList("NAMES " + msg.params[2], msg);
break;
case irc.RPL_ENDOFNAMES:
this.endPendingList("NAMES " + msg.params[1], msg);
break;
case irc.RPL_WHOISUSER:
case irc.RPL_WHOISSERVER:
case irc.RPL_WHOISOPERATOR:
case irc.RPL_WHOISIDLE:
case irc.RPL_WHOISCHANNELS:
case irc.RPL_ENDOFWHOIS:
let nick = msg.params[1];
if (!this.whoisDB.has(nick)) {
this.whoisDB.set(nick, {});
}
this.whoisDB.get(nick)[msg.command] = msg;
this.pushPendingList("WHOIS " + msg.params[1], msg);
break;
case irc.ERR_NICKLOCKED:
case irc.ERR_SASLFAIL:
case irc.ERR_SASLTOOLONG:
case irc.ERR_SASLABORTED:
case irc.ERR_SASLALREADY:
this.dispatchEvent(new CustomEvent("error", { detail: "SASL error (" + msg.command + "): " + msg.params[1] }));
this.disconnect();
case irc.RPL_ENDOFWHOIS:
this.endPendingList("WHOIS " + msg.params[1], msg);
break;
case irc.RPL_WHOREPLY:
case irc.RPL_WHOSPCRPL:
this.pushPendingList("WHO", msg);
break;
case irc.RPL_ENDOFWHO:
this.endPendingList("WHO", msg);
break;
case "PING":
this.send({ command: "PONG", params: [msg.params[0]] });
break;
case "NICK":
let newNick = msg.params[0];
if (msg.prefix.name == this.nick) {
if (this.isMyNick(msg.prefix.name)) {
this.nick = newNick;
}
break;
@@ -311,7 +403,7 @@ export default class Client extends EventTarget {
}
break;
case "ERROR":
this.dispatchEvent(new CustomEvent("error", { detail: "Fatal IRC error: " + msg.params[0] }));
this.dispatchError(new IRCError(msg));
this.disconnect();
break;
case irc.ERR_PASSWDMISMATCH:
@@ -321,18 +413,26 @@ export default class Client extends EventTarget {
case irc.ERR_UNAVAILRESOURCE:
case irc.ERR_NOPERMFORHOST:
case irc.ERR_YOUREBANNEDCREEP:
this.dispatchEvent(new CustomEvent("error", { detail: "Error (" + msg.command + "): " + msg.params[msg.params.length - 1] }));
this.dispatchError(new IRCError(msg));
if (this.status != Client.Status.REGISTERED) {
this.disconnect();
}
break;
case "FAIL":
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.dispatchEvent(new CustomEvent("error", {
detail: "Failed to bind to bouncer network: " + msg.params[3],
this.dispatchError(new Error("Failed to bind to bouncer network", {
cause: new IRCError(msg),
}));
this.disconnect();
}
if (msg.params[1] === "ACCOUNT_REQUIRED") {
this.dispatchError(new IRCError(msg));
this.disconnect();
}
break;
}
@@ -347,11 +447,48 @@ export default class Client extends EventTarget {
}
}
authenticate(mechanism, params) {
if (!this.supportsSASL(mechanism)) {
throw new Error(`${mechanism} authentication not supported by the server`);
}
console.log(`Starting SASL ${mechanism} authentication`);
// Send the first SASL response immediately to avoid a roundtrip
let initialResp = null;
switch (mechanism) {
case "PLAIN":
let respStr = btoa("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [btoa("")] };
break;
default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
}
let startMsg = { command: "AUTHENTICATE", params: [mechanism] };
let promise = this.roundtrip(startMsg, (msg) => {
switch (msg.command) {
case irc.RPL_SASLSUCCESS:
return true;
case irc.ERR_NICKLOCKED:
case irc.ERR_SASLFAIL:
case irc.ERR_SASLTOOLONG:
case irc.ERR_SASLABORTED:
case irc.ERR_SASLALREADY:
throw new IRCError(msg);
}
});
this.send(initialResp);
return promise;
}
who(mask, options) {
let params = [mask];
let fields = "", token = "";
if (options && this.isupport.has("WHOX")) {
if (options && this.isupport.whox()) {
let match = ""; // Matches exact channel or nick
fields = "t"; // Always include token in reply
@@ -373,27 +510,30 @@ export default class Client extends EventTarget {
let msg = { command: "WHO", params };
let l = [];
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.RPL_WHOREPLY:
// TODO: match with mask
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_WHOSPCRPL:
if (msg.params.length !== fields.length || msg.params[1] !== token) {
let promise = this.pendingCmds.WHO.then(() => {
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.RPL_WHOREPLY:
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_WHOSPCRPL:
if (msg.params.length !== fields.length || msg.params[1] !== token) {
break;
}
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) {
return l;
}
break;
}
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) {
return l;
}
break;
}
}).finally(() => {
this.whoxQueries.delete(token);
}).finally(() => {
this.whoxQueries.delete(token);
});
});
this.pendingCmds.WHO = promise.catch(() => {});
return promise;
}
parseWhoReply(msg) {
@@ -424,6 +564,10 @@ export default class Client extends EventTarget {
who[k] = msg.params[2 + i];
i++;
});
if (who.account === "0") {
// WHOX uses "0" to mean "no account"
who.account = null;
}
return who;
default:
throw new Error("Not a WHO reply: " + msg.command);
@@ -439,146 +583,120 @@ export default class Client extends EventTarget {
case irc.RPL_ENDOFWHOIS:
nick = msg.params[1];
if (this.cm(nick) === targetCM) {
return this.whoisDB.get(nick);
let whois = {};
msg.list.forEach((reply) => {
whois[reply.command] = reply;
});
return whois;
}
break;
case irc.ERR_NOSUCHNICK:
nick = msg.params[1];
if (this.cm(nick) === targetCM) {
throw msg;
throw new IRCError(msg);
}
break;
}
});
}
addAvailableCaps(s) {
let l = s.split(" ");
l.forEach((s) => {
let i = s.indexOf("=");
let k = s, v = "";
if (i >= 0) {
k = s.slice(0, i);
v = s.slice(i + 1);
}
this.availableCaps[k.toLowerCase()] = v;
});
}
supportsSASL(mech) {
let saslCap = this.availableCaps["sasl"];
let saslCap = this.caps.available.get("sasl");
if (saslCap === undefined) {
return false;
}
return saslCap.split(",").includes(mech);
}
requestCaps(extra) {
let reqCaps = extra || [];
checkAccountRegistrationCap(k) {
let v = this.caps.available.get("draft/account-registration");
if (v === undefined) {
return false;
}
return v.split(",").includes(k);
}
permanentCaps.forEach((cap) => {
if (this.availableCaps[cap] !== undefined && !this.enabledCaps[cap]) {
reqCaps.push(cap);
}
});
requestCaps() {
let wantCaps = [].concat(permanentCaps);
if (!this.params.bouncerNetwork) {
wantCaps.push("soju.im/bouncer-networks-notify");
}
if (reqCaps.length > 0) {
this.send({ command: "CAP", params: ["REQ", reqCaps.join(" ")] });
let msg = this.caps.requestAvailable(wantCaps);
if (msg) {
this.send(msg);
}
}
handleCap(msg) {
this.caps.parse(msg);
let subCmd = msg.params[1];
let args = msg.params.slice(2);
switch (subCmd) {
case "LS":
this.addAvailableCaps(args[args.length - 1]);
if (args[0] != "*") {
console.log("Available server caps:", this.availableCaps);
this.supportsCap = true;
if (args[0] == "*") {
break;
}
let reqCaps = [];
let capEnd = true;
if (this.params.saslPlain && this.supportsSASL("PLAIN")) {
// CAP END is deferred after authentication finishes
reqCaps.push("sasl");
capEnd = false;
console.log("Available server caps:", this.caps.available);
this.requestCaps();
if (this.status !== Client.Status.REGISTERED) {
if (this.caps.available.has("sasl")) {
let promise;
if (this.params.saslPlain) {
promise = this.authenticate("PLAIN", this.params.saslPlain);
} else if (this.params.saslExternal) {
promise = this.authenticate("EXTERNAL");
}
(promise || Promise.resolve()).catch((err) => {
this.dispatchError(err);
this.disconnect();
});
}
if (!this.params.bouncerNetwork && this.availableCaps["soju.im/bouncer-networks-notify"] !== undefined) {
reqCaps.push("soju.im/bouncer-networks-notify");
if (this.caps.available.has("soju.im/bouncer-networks") && this.params.bouncerNetwork) {
this.send({ command: "BOUNCER", params: ["BIND", this.params.bouncerNetwork] });
}
this.requestCaps(reqCaps);
if (this.status != Client.Status.REGISTERED && capEnd) {
this.send({ command: "CAP", params: ["END"] });
}
this.send({ command: "CAP", params: ["END"] });
}
break;
case "NEW":
this.addAvailableCaps(args[0]);
console.log("Server added available caps:", args[0]);
this.requestCaps();
break;
case "DEL":
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
delete this.availableCaps[cap];
delete this.enabledCaps[cap];
});
console.log("Server removed available caps:", args[0]);
break;
case "ACK":
console.log("Server ack'ed caps:", args[0]);
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
this.enabledCaps[cap] = true;
if (cap == "sasl" && this.params.saslPlain) {
console.log("Starting SASL PLAIN authentication");
this.send({ command: "AUTHENTICATE", params: ["PLAIN"] });
}
});
break;
case "NAK":
console.log("Server nak'ed caps:", args[0]);
if (this.status != Client.Status.REGISTERED) {
if (this.status !== Client.Status.REGISTERED) {
this.send({ command: "CAP", params: ["END"] });
}
break;
}
}
handleAuthenticate(msg) {
let challengeStr = msg.params[0];
// For now only PLAIN is supported
if (challengeStr != "+") {
this.dispatchEvent(new CustomEvent("error", { detail: "Expected an empty challenge, got: " + challengeStr }));
this.send({ command: "AUTHENTICATE", params: ["*"] });
return;
}
let respStr = btoa("\0" + this.params.saslPlain.username + "\0" + this.params.saslPlain.password);
this.send({ command: "AUTHENTICATE", params: [respStr] });
}
send(msg) {
if (!this.ws) {
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
}
this.ws.send(irc.formatMessage(msg));
console.debug("Sent:", msg);
if (this.debug) {
console.debug("Sent:", msg);
}
}
setCaseMapping(name) {
this.cm = irc.CaseMapping.byName(name);
if (!this.cm) {
console.error("Unsupported case-mapping '" + name + "', falling back to RFC 1459");
this.cm = irc.CaseMapping.RFC1459;
}
this.whoisDB = new irc.CaseMapMap(this.whoisDB, this.cm);
updateCaseMapping() {
this.cm = this.isupport.caseMapping();
this.pendingLists = new irc.CaseMapMap(this.pendingLists, this.cm);
this.monitored = new irc.CaseMapMap(this.monitored, this.cm);
}
@@ -591,7 +709,7 @@ export default class Client extends EventTarget {
}
isChannel(name) {
let chanTypes = this.isupport.get("CHANTYPES") || irc.STD_CHANTYPES;
let chanTypes = this.isupport.chanTypes();
return chanTypes.indexOf(name[0]) >= 0;
}
@@ -613,8 +731,10 @@ export default class Client extends EventTarget {
/* Execute a command that expects a response. `done` is called with message
* events until it returns a truthy value. */
roundtrip(msg, done) {
let cmd = msg.command;
let label;
if (this.enabledCaps["labeled-response"]) {
if (this.caps.enabled.has("labeled-response")) {
lastLabel++;
label = String(lastLabel);
msg.tags = { ...msg.tags, label };
@@ -631,6 +751,24 @@ export default class Client extends EventTarget {
return;
}
let isError = false;
switch (msg.command) {
case "FAIL":
isError = msg.params[0] === cmd;
break;
case irc.ERR_UNKNOWNERROR:
case irc.ERR_UNKNOWNCOMMAND:
case irc.ERR_NEEDMOREPARAMS:
case irc.RPL_TRYAGAIN:
isError = msg.params[1] === cmd;
break;
}
if (isError) {
removeEventListeners();
reject(new IRCError(msg));
return;
}
let result;
try {
result = done(msg);
@@ -664,9 +802,36 @@ export default class Client extends EventTarget {
});
}
join(channel) {
let msg = {
command: "JOIN",
params: [channel],
};
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.ERR_NOSUCHCHANNEL:
case irc.ERR_TOOMANYCHANNELS:
case irc.ERR_BADCHANNELKEY:
case irc.ERR_BANNEDFROMCHAN:
case irc.ERR_CHANNELISFULL:
case irc.ERR_INVITEONLYCHAN:
if (this.cm(msg.params[1]) === this.cm(channel)) {
throw new IRCError(msg);
}
break;
case "JOIN":
if (this.isMyNick(msg.prefix.name) && this.cm(msg.params[0]) === this.cm(channel)) {
return true;
}
break;
}
});
}
fetchBatch(msg, batchType) {
let batchName = null;
let messages = [];
let cmd = msg.command;
return this.roundtrip(msg, (msg) => {
if (batchName) {
let batch = msg.batch;
@@ -679,23 +844,18 @@ export default class Client extends EventTarget {
}
}
switch (msg.command) {
case "BATCH":
let enter = msg.params[0].startsWith("+");
let name = msg.params[0].slice(1);
if (enter && msg.params[1] === batchType) {
batchName = name;
break;
}
if (!enter && name === batchName) {
return { ...this.batches.get(name), messages };
}
break;
case "FAIL":
if (msg.params[0] === msg.command) {
throw msg;
}
break;
if (msg.command !== "BATCH") {
return;
}
let enter = msg.params[0].startsWith("+");
let name = msg.params[0].slice(1);
if (enter && msg.params[1] === batchType) {
batchName = name;
return;
}
if (!enter && name === batchName) {
return { ...this.batches.get(name), messages };
}
});
}
@@ -703,29 +863,20 @@ export default class Client extends EventTarget {
roundtripChatHistory(params) {
// Don't send multiple CHATHISTORY commands in parallel, we can't
// properly handle batches and errors.
this.pendingHistory = this.pendingHistory.catch(() => {}).then(() => {
let promise = this.pendingCmds.CHATHISTORY.then(() => {
let msg = {
command: "CHATHISTORY",
params,
};
return this.fetchBatch(msg, "chathistory").then((batch) => batch.messages);
});
return this.pendingHistory;
}
chatHistoryPageSize() {
if (this.isupport.has("CHATHISTORY")) {
let pageSize = parseInt(this.isupport.get("CHATHISTORY"), 10);
if (pageSize > 0) {
return pageSize;
}
}
return 100;
this.pendingCmds.CHATHISTORY = promise.catch(() => {});
return promise;
}
/* Fetch one page of history before the given date. */
fetchHistoryBefore(target, before, limit) {
let max = Math.min(limit, this.chatHistoryPageSize());
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { more: messages.length >= max };
@@ -734,7 +885,7 @@ export default class Client extends EventTarget {
/* Fetch history in ascending order. */
fetchHistoryBetween(target, after, before, limit) {
let max = Math.min(limit, this.chatHistoryPageSize());
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;
@@ -769,10 +920,6 @@ export default class Client extends EventTarget {
}
listBouncerNetworks() {
if (!this.enabledCaps["soju.im/bouncer-networks"]) {
return Promise.reject(new Error("Server doesn't support the BOUNCER extension"));
}
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
let networks = new Map();
@@ -786,13 +933,6 @@ export default class Client extends EventTarget {
});
}
maxMonitorTargets() {
if (!this.isupport.has("MONITOR")) {
return 0;
}
return parseInt(this.isupport.get("MONITOR"), 10);
}
monitor(target) {
if (this.monitored.has(target)) {
return;
@@ -801,7 +941,7 @@ export default class Client extends EventTarget {
this.monitored.set(target, true);
// TODO: add poll-based fallback when MONITOR is not supported
if (this.monitored.size + 1 > this.maxMonitorTargets()) {
if (this.monitored.size + 1 > this.isupport.monitor()) {
return;
}
@@ -815,10 +955,53 @@ export default class Client extends EventTarget {
this.monitored.delete(target);
if (!this.isupport.has("MONITOR")) {
if (this.isupport.monitor() <= 0) {
return;
}
this.send({ command: "MONITOR", params: ["-", target] });
}
createBouncerNetwork(attrs) {
let msg = {
command: "BOUNCER",
params: ["ADDNETWORK", irc.formatTags(attrs)],
};
return this.roundtrip(msg, (msg) => {
if (msg.command === "BOUNCER" && msg.params[0] === "ADDNETWORK") {
return msg.params[1];
}
});
}
registerAccount(email, password) {
let msg = {
command: "REGISTER",
params: ["*", email || "*", password],
};
return this.roundtrip(msg, (msg) => {
if (msg.command !== "REGISTER") {
return;
}
let result = msg.params[0];
return {
verificationRequired: result === "VERIFICATION_REQUIRED",
account: msg.params[1],
message: msg.params[2],
};
});
}
verifyAccount(account, code) {
let msg = {
command: "VERIFY",
params: [account, code],
};
return this.roundtrip(msg, (msg) => {
if (msg.command !== "VERIFY") {
return;
}
return { message: msg.params[2] };
});
}
}

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 "../node_modules/anchorme/dist/browser/anchorme.min.js";
export const anchorme = window.anchorme;
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js";
export { linkifyjs };

View File

@@ -4,6 +4,9 @@ export const RPL_YOURHOST = "002";
export const RPL_CREATED = "003";
export const RPL_MYINFO = "004";
export const RPL_ISUPPORT = "005";
export const RPL_UMODEIS = "221";
export const RPL_TRYAGAIN = "263";
export const RPL_AWAY = "301";
export const RPL_WHOISUSER = "311";
export const RPL_WHOISSERVER = "312";
export const RPL_WHOISOPERATOR = "313";
@@ -12,7 +15,6 @@ export const RPL_ENDOFWHOIS = "318";
export const RPL_WHOISCHANNELS = "319";
export const RPL_ENDOFWHO = "315";
export const RPL_CHANNELMODEIS = "324";
export const RPL_CREATIONTIME = "329";
export const RPL_NOTOPIC = "331";
export const RPL_TOPIC = "332";
export const RPL_TOPICWHOTIME = "333";
@@ -30,17 +32,27 @@ export const RPL_ENDOFBANLIST = "368";
export const RPL_MOTD = "372";
export const RPL_MOTDSTART = "375";
export const RPL_ENDOFMOTD = "376";
export const ERR_UNKNOWNERROR = "400";
export const ERR_NOSUCHNICK = "401";
export const ERR_NOSUCHCHANNEL = "403";
export const ERR_TOOMANYCHANNELS = "405";
export const ERR_UNKNOWNCOMMAND = "421";
export const ERR_NOMOTD = "422";
export const ERR_ERRONEUSNICKNAME = "432";
export const ERR_NICKNAMEINUSE = "433";
export const ERR_NICKCOLLISION = "436";
export const ERR_NEEDMOREPARAMS = "461";
export const ERR_NOPERMFORHOST = "463";
export const ERR_PASSWDMISMATCH = "464";
export const ERR_YOUREBANNEDCREEP = "465";
export const ERR_CHANNELISFULL = "471";
export const ERR_INVITEONLYCHAN = "473";
export const ERR_BANNEDFROMCHAN = "474";
export const ERR_BADCHANNELKEY = "475";
// RFC 2812
export const ERR_UNAVAILRESOURCE = "437";
// Other
export const RPL_CREATIONTIME = "329";
export const RPL_QUIETLIST = "728";
export const RPL_ENDOFQUIETLIST = "729";
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
@@ -121,32 +133,32 @@ export function parsePrefix(s) {
host: null,
};
let host = null;
let i = s.indexOf("@");
if (i < 0) {
prefix.name = s;
return prefix;
if (i > 0) {
host = s.slice(i + 1);
s = s.slice(0, i);
}
prefix.host = s.slice(i + 1);
s = s.slice(0, i);
let user = null;
i = s.indexOf("!");
if (i < 0) {
prefix.name = s;
return prefix;
if (i > 0) {
user = s.slice(i + 1);
s = s.slice(0, i);
}
prefix.name = s.slice(0, i);
prefix.user = s.slice(i + 1);
return prefix;
return { name: s, user, host };
}
function formatPrefix(prefix) {
if (!prefix.host) {
return prefix.name;
let s = prefix.name;
if (prefix.user) {
s += "!" + prefix.user;
}
if (!prefix.user) {
return prefix.name + "@" + prefix.host;
if (prefix.host) {
s += "@" + prefix.host;
}
return prefix.name + "!" + prefix.user + "@" + prefix.host;
return s;
}
export function parseMessage(s) {
@@ -363,28 +375,95 @@ export function parseCTCP(msg) {
return ctcp;
}
export function parseISUPPORT(tokens, params) {
let changed = [];
tokens.forEach((tok) => {
if (tok.startsWith("-")) {
let k = tok.slice(1);
params.delete(k.toUpperCase());
return;
}
let i = tok.indexOf("=");
let k = tok, v = "";
if (i >= 0) {
k = tok.slice(0, i);
v = tok.slice(i + 1);
}
k = k.toUpperCase();
params.set(k, v);
changed.push(k);
function unescapeISUPPORTValue(s) {
return s.replace(/\\x[0-9A-Z]{2}/gi, (esc) => {
let hex = esc.slice(2);
return String.fromCharCode(parseInt(hex, 16));
});
return changed;
}
export class Isupport {
raw = new Map();
parse(tokens) {
tokens.forEach((tok) => {
if (tok.startsWith("-")) {
let k = tok.slice(1);
this.raw.delete(k.toUpperCase());
return;
}
let i = tok.indexOf("=");
let k = tok, v = "";
if (i >= 0) {
k = tok.slice(0, i);
v = unescapeISUPPORTValue(tok.slice(i + 1));
}
k = k.toUpperCase();
this.raw.set(k, v);
});
}
caseMapping() {
let name = this.raw.get("CASEMAPPING");
if (!name) {
return CaseMapping.RFC1459;
}
let cm = CaseMapping.byName(name);
if (!cm) {
console.error("Unsupported case-mapping '" + name + "', falling back to RFC 1459");
return CaseMapping.RFC1459;
}
return cm;
}
monitor() {
if (!this.raw.has("MONITOR")) {
return 0;
}
let v = this.raw.get("MONITOR");
if (v === "") {
return Infinity;
}
return parseInt(v, 10);
}
whox() {
return this.raw.has("WHOX");
}
prefix() {
return this.raw.get("PREFIX") || "";
}
chanTypes() {
return this.raw.get("CHANTYPES") || STD_CHANTYPES;
}
statusMsg() {
return this.raw.get("STATUSMSG");
}
network() {
return this.raw.get("NETWORK");
}
chatHistory() {
if (!this.raw.has("CHATHISTORY")) {
return 0;
}
let n = parseInt(this.raw.get("CHATHISTORY"), 10);
if (n <= 0) {
return Infinity;
}
return n;
}
bouncerNetID() {
return this.raw.get("BOUNCER_NETID");
}
}
export const CaseMapping = {
@@ -593,8 +672,8 @@ export function getMessageLabel(msg) {
}
export function forEachChannelModeUpdate(msg, isupport, callback) {
let chanmodes = isupport.get("CHANMODES") || STD_CHANMODES;
let prefix = isupport.get("PREFIX") || "";
let chanmodes = isupport.chanModes();
let prefix = isupport.prefix();
let typeByMode = new Map();
let [a, b, c, d] = chanmodes.split(",");
@@ -640,6 +719,8 @@ export function forEachChannelModeUpdate(msg, isupport, callback) {
}
/**
* Check if a realname is worth displaying.
*
* Since the realname is mandatory, many clients set a meaningless realname.
*/
export function isMeaningfulRealname(realname, nick) {
@@ -647,7 +728,7 @@ export function isMeaningfulRealname(realname, nick) {
return false;
}
if (realname.toLowerCase() === "realname") {
if (realname.toLowerCase() === "realname" || realname.toLowerCase() === "unknown" || realname.toLowerCase() === "fullname") {
return false;
}
@@ -655,3 +736,125 @@ export function isMeaningfulRealname(realname, nick) {
return true;
}
/* Parse an irc:// URL.
*
* See: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
*/
export function parseURL(str) {
if (!str.startsWith("irc://") && !str.startsWith("ircs://")) {
return null;
}
str = str.slice(str.indexOf(":") + "://".length);
let loc;
let i = str.indexOf("/");
if (i < 0) {
loc = str;
str = "";
} else {
loc = str.slice(0, i);
str = str.slice(i + 1);
}
let host = loc;
i = loc.indexOf("@");
if (i >= 0) {
host = loc.slice(i + 1);
// TODO: parse authinfo
}
i = str.indexOf("?");
if (i >= 0) {
str = str.slice(0, i);
// TODO: parse options
}
let enttype;
i = str.indexOf(",");
if (i >= 0) {
let flags = str.slice(i + 1).split(",");
str = str.slice(0, i);
if (flags.indexOf("isuser") >= 0) {
enttype = "user";
} else if (flags.indexOf("ischannel") >= 0) {
enttype = "channel";
}
// TODO: parse hosttype
}
let entity = decodeURIComponent(str);
if (!enttype) {
// TODO: technically we should use the PREFIX ISUPPORT here
enttype = entity.startsWith("#") ? "channel" : "user";
}
return { host, enttype, entity };
}
export class CapRegistry {
available = new Map();
enabled = new Set();
addAvailable(s) {
let l = s.split(" ");
l.forEach((s) => {
let i = s.indexOf("=");
let k = s, v = "";
if (i >= 0) {
k = s.slice(0, i);
v = s.slice(i + 1);
}
this.available.set(k.toLowerCase(), v);
});
}
parse(msg) {
if (msg.command !== "CAP") {
return;
}
let subCmd = msg.params[1];
let args = msg.params.slice(2);
switch (subCmd) {
case "LS":
this.addAvailable(args[args.length - 1]);
break;
case "NEW":
this.addAvailable(args[0]);
break;
case "DEL":
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
this.available.delete(cap);
this.enabled.delete(cap);
});
break;
case "ACK":
// TODO: handle `ACK -cap` to
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
if (cap.startsWith("-")) {
this.enabled.delete(cap.slice(1));
} else {
this.enabled.add(cap);
}
});
break;
}
}
requestAvailable(l) {
l = l.filter((cap) => {
return this.available.has(cap) && !this.enabled.has(cap);
});
if (l.length === 0) {
return null;
}
return { command: "CAP", params: ["REQ", l.join(" ")] };
}
}

View File

@@ -1,65 +1,68 @@
import { anchorme, html } from "./index.js";
import { linkifyjs, html } from "./index.js";
function linkifyChannel(text, transformChannel) {
// Don't match punctuation at the end of the channel name
const channelRegex = /(^|\s)(#[^\s]+[^\s.?!…():;,])/gi;
linkifyjs.options.defaults.defaultProtocol = "https";
let children = [];
let match;
let last = 0;
while ((match = channelRegex.exec(text)) !== null) {
let channel = match[2];
let start = match.index + match[1].length;
let end = start + match[2].length;
linkifyjs.registerCustomProtocol("irc");
linkifyjs.registerCustomProtocol("ircs");
children.push(text.substring(last, start));
children.push(transformChannel(channel));
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser, utils }) => {
const { POUND, DOMAIN, TLD, LOCALHOST, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
const START_STATE = parser.start;
last = end;
}
children.push(text.substring(last));
const Channel = utils.createTokenClass("ircChannel", {
isLink: true,
toHref() {
return "irc:///" + this.toString();
},
});
return children;
}
const HASH_STATE = START_STATE.tt(POUND);
export default function linkify(text, onChannelClick) {
function transformChannel(channel) {
function onClick(event) {
event.preventDefault();
onChannelClick(channel);
}
return html`
<a
href="irc:///${encodeURIComponent(channel)}"
onClick=${onClick}
>${channel}</a>`;
}
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);
let links = anchorme.list(text);
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);
});
export default function linkify(text, onClick) {
let links = linkifyjs.find(text);
let children = [];
let last = 0;
links.forEach((match) => {
if (!match.isLink) {
return;
}
const prefix = text.substring(last, match.start)
children.push(...linkifyChannel(prefix, transformChannel));
children.push(prefix);
let proto = match.protocol || "https://";
if (match.isEmail) {
proto = "mailto:";
}
let url = match.string;
if (!url.startsWith(proto)) {
url = proto + url;
}
children.push(html`<a href=${url} target="_blank" rel="noreferrer noopener">${match.string}</a>`);
children.push(html`
<a
href=${match.href}
target="_blank"
rel="noreferrer noopener"
onClick=${onClick}
>${match.value}</a>
`);
last = match.end;
});
const suffix = text.substring(last)
children.push(...linkifyChannel(suffix, transformChannel));
children.push(suffix);
return children;
}

4
main.js Normal file
View File

@@ -0,0 +1,4 @@
import { html, render } from "./lib/index.js";
import App from "./components/app.js";
render(html`<${App}/>`, document.body);

14249
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,28 @@
{
"name": "gamja",
"type": "module",
"dependencies": {
"anchorme": "^2.1.2",
"htm": "^3.0.4",
"linkifyjs": "^3.0.2",
"preact": "^10.5.9"
},
"devDependencies": {
"http-server": "^13.0.2"
"@parcel/packager-raw-url": "^2.0.0",
"@parcel/transformer-webmanifest": "^2.0.0",
"node-static": "^0.7.11",
"parcel": "^2.0.0",
"split": "^1.0.1",
"ws": "^8.3.0"
},
"scripts": {
"start": "http-server ."
"start": "node ./dev-server.js",
"build": "parcel build"
},
"private": true
"private": true,
"targets": {
"default": {
"source": "index.html",
"publicUrl": "."
}
}
}

148
state.js
View File

@@ -63,20 +63,26 @@ export function getMessageURL(buf, msg) {
}
}
export function getServerName(server, bouncerNetwork, isBouncer) {
if (bouncerNetwork && bouncerNetwork.name) {
export function getServerName(server, bouncerNetwork) {
let netName = server.name;
if (bouncerNetwork && bouncerNetwork.name && bouncerNetwork.name !== bouncerNetwork.host) {
// User has picked a custom name for the network, use that
return bouncerNetwork.name;
}
if (isBouncer) {
return "bouncer";
}
let netName = server.isupport.get("NETWORK");
if (netName) {
// Server has specified a name
return netName;
}
return "server";
if (bouncerNetwork) {
return bouncerNetwork.name || bouncerNetwork.host || "server";
} else if (server.isBouncer) {
return "bouncer";
} else {
return "server";
}
}
function updateState(state, updater) {
@@ -112,7 +118,7 @@ function compareBuffers(a, b) {
}
function updateMembership(membership, letter, add, client) {
let prefix = client.isupport.get("PREFIX") || "";
let prefix = client.isupport.prefix();
let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
return [membership.prefix, i];
@@ -137,7 +143,7 @@ function updateMembership(membership, letter, add, client) {
function insertMessage(list, msg) {
if (list.length == 0) {
return [msg];
} else if (list[list.length - 1].tags.time <= msg.tags.time) {
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
return list.concat(msg);
}
@@ -225,7 +231,7 @@ export const State = {
let cm = irc.CaseMapping.RFC1459;
let server = state.servers.get(serverID);
if (server) {
cm = irc.CaseMapping.byName(server.isupport.get("CASEMAPPING")) || cm;
cm = server.cm;
}
let nameCM = cm(name);
@@ -246,9 +252,17 @@ export const State = {
let servers = new Map(state.servers);
servers.set(id, {
id,
name: null, // from ISUPPORT NETWORK
status: ServerStatus.DISCONNECTED,
isupport: new Map(),
cm: irc.CaseMapping.RFC1459,
users: new irc.CaseMapMap(null, irc.CaseMapping.RFC1459),
account: null,
supportsSASLPlain: false,
supportsAccountRegistration: false,
reliableUserAccounts: false,
statusMsg: null, // from ISUPPORT STATUSMSG
isBouncer: false,
bouncerNetID: null,
});
return [id, { servers }];
},
@@ -277,6 +291,7 @@ export const State = {
type,
server: serverID,
serverInfo: null, // if server
joined: false, // if channel
topic: null, // if channel
members: new irc.CaseMapMap(null, client.cm), // if channel
messages: [],
@@ -311,7 +326,7 @@ export const State = {
return;
}
let target, channel, topic, targets, who;
let target, channel, topic, targets, who, update, buffers;
switch (msg.command) {
case irc.RPL_MYINFO:
// TODO: parse available modes
@@ -321,7 +336,7 @@ export const State = {
};
return updateBuffer(SERVER_BUFFER, { serverInfo });
case irc.RPL_ISUPPORT:
let buffers = new Map(state.buffers);
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
@@ -333,11 +348,31 @@ export const State = {
buffers,
...updateServer((server) => {
return {
isupport: new Map(client.isupport),
name: client.isupport.network(),
cm: client.cm,
users: new irc.CaseMapMap(server.users, client.cm),
reliableUserAccounts: client.isupport.monitor() > 0 && client.isupport.whox(),
statusMsg: client.isupport.statusMsg(),
bouncerNetID: client.isupport.bouncerNetID(),
};
}),
};
case "CAP":
return updateServer({
supportsSASLPlain: client.supportsSASL("PLAIN"),
supportsAccountRegistration: client.caps.enabled.has("draft/account-registration"),
isBouncer: client.caps.enabled.has("soju.im/bouncer-networks"),
});
case irc.RPL_LOGGEDIN:
return updateServer({ account: msg.params[2] });
case irc.RPL_LOGGEDOUT:
return updateServer({ account: null });
case "REGISTER":
case "VERIFY":
if (msg.params[0] === "SUCCESS") {
return updateServer({ account: msg.params[1] });
}
break;
case irc.RPL_NOTOPIC:
channel = msg.params[1];
return updateBuffer(channel, { topic: null });
@@ -348,19 +383,19 @@ export const State = {
case irc.RPL_TOPICWHOTIME:
// Ignore
break;
case irc.RPL_NAMREPLY:
channel = msg.params[2];
let membersList = msg.params[3].split(" ");
case irc.RPL_ENDOFNAMES:
channel = msg.params[1];
return updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
membersList.forEach((s) => {
let member = irc.parseTargetPrefix(s);
members.set(member.name, member.prefix);
let members = new irc.CaseMapMap(null, buf.members.caseMap);
msg.list.forEach((namreply) => {
let membersList = namreply.params[3].split(" ");
membersList.forEach((s) => {
let member = irc.parseTargetPrefix(s);
members.set(member.name, member.prefix);
});
});
return { members };
});
case irc.RPL_ENDOFNAMES:
break;
case irc.RPL_WHOREPLY:
case irc.RPL_WHOSPCRPL:
@@ -368,6 +403,7 @@ export const State = {
if (who.flags !== undefined) {
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
who.operator = who.flags.indexOf("*") >= 0;
delete who.flags;
}
@@ -376,14 +412,9 @@ export const State = {
return updateUser(who.nick, who);
case irc.RPL_ENDOFWHO:
target = msg.params[1];
if (!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) => {
// TODO: mark user offline if we have old WHO info but this
// WHO reply is empty
if (user) {
return;
}
return { offline: true };
});
}
@@ -396,10 +427,13 @@ export const State = {
state = { ...state, ...update };
}
let update = updateBuffer(channel, (buf) => {
update = updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
members.set(msg.prefix.name, "");
return { members };
let joined = buf.joined || client.isMyNick(msg.prefix.name);
return { members, joined };
});
state = { ...state, ...update };
@@ -427,7 +461,10 @@ export const State = {
return updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
members.delete(msg.prefix.name);
return { members };
let joined = buf.joined && !client.isMyNick(msg.prefix.name);
return { members, joined };
});
case "KICK":
channel = msg.params[0];
@@ -436,18 +473,54 @@ export const State = {
return updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
members.delete(nick);
return { members };
let joined = buf.joined && !client.isMyNick(nick);
return { members, joined };
});
case "QUIT":
return updateUser(msg.prefix.name, (user) => {
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
return;
}
let members = new irc.CaseMapMap(buf.members);
members.delete(msg.prefix.name);
buffers.set(buf.id, { ...buf, members });
});
state = { ...state, buffers };
update = updateUser(msg.prefix.name, (user) => {
if (!user) {
return;
}
return { offline: true };
});
state = { ...state, ...update };
return state;
case "NICK":
let newNick = msg.params[0];
return updateServer((server) => {
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
return;
}
let members = new irc.CaseMapMap(buf.members);
members.set(newNick, members.get(msg.prefix.name));
members.delete(msg.prefix.name);
buffers.set(buf.id, { ...buf, members });
});
state = { ...state, buffers };
update = updateServer((server) => {
let users = new irc.CaseMapMap(server.users);
let user = users.get(msg.prefix.name);
if (!user) {
@@ -457,6 +530,9 @@ export const State = {
users.delete(msg.prefix.name);
return { users };
});
state = { ...state, ...update };
return state;
case "SETNAME":
return updateUser(msg.prefix.name, { realname: msg.params[0] });
case "CHGHOST":
@@ -484,7 +560,7 @@ export const State = {
return; // TODO: handle user mode changes too
}
let prefix = client.isupport.get("PREFIX") || "";
let prefix = client.isupport.prefix();
let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
return [membership.mode, membership.prefix];
}));

View File

@@ -23,6 +23,7 @@ class Item {
}
export const autoconnect = new Item("autoconnect");
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
const rawReceipts = new Item("receipts");

View File

@@ -58,6 +58,13 @@ body {
font-family: monospace;
}
noscript {
display: block;
margin: 0 auto;
max-width: 600px;
grid-column-start: 2;
}
button {
background: var(--button-background);
transition: background 0.25s linear;
@@ -279,6 +286,9 @@ button.danger:hover {
padding: 2px 10px;
box-sizing: border-box;
}
#member-list li a.away {
color: var(--gray);
}
.membership.owner {
color: red;