Compare commits
119 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11f58b975 | ||
|
|
4704e0f12f | ||
|
|
43f1329fb0 | ||
|
|
4cabae89ff | ||
|
|
f6895fed32 | ||
|
|
fc93a8cef5 | ||
|
|
f3d38859d3 | ||
|
|
f81c564d23 | ||
|
|
ab3d4dd661 | ||
|
|
31b293fa03 | ||
|
|
f9ec578fce | ||
|
|
305f510501 | ||
|
|
05f7c6e9fe | ||
|
|
fc8aa30756 | ||
|
|
8c8bd43638 | ||
|
|
30e3ec392f | ||
|
|
ada9ff3b71 | ||
|
|
93ba0e6443 | ||
|
|
07c9cdebb6 | ||
|
|
aef2812348 | ||
|
|
a1ff1be342 | ||
|
|
47f56f06b9 | ||
|
|
1e84412172 | ||
|
|
451bb4c73f | ||
|
|
be08302c1f | ||
|
|
b1d5f1436e | ||
|
|
c4a78283af | ||
|
|
25e69a551e | ||
|
|
86853eb2e5 | ||
|
|
1800b6bea1 | ||
|
|
fcce340846 | ||
|
|
e29ccf7220 | ||
|
|
d8d2cbe0f7 | ||
|
|
0d2067e33e | ||
|
|
3e309e9dfe | ||
|
|
3e2ac307f6 | ||
|
|
24b50a332c | ||
|
|
adefc620de | ||
|
|
bc3abbec32 | ||
|
|
4f927b5536 | ||
|
|
86b08296a0 | ||
|
|
25dd6aabf6 | ||
|
|
0af40a1a8e | ||
|
|
51bf8da3d6 | ||
|
|
723951a07b | ||
|
|
c4c0a77162 | ||
|
|
3f2553291f | ||
|
|
debd50f482 | ||
|
|
a57428002f | ||
|
|
bbfeb5bcbc | ||
|
|
0980983bdc | ||
|
|
e37c2a2cec | ||
|
|
82e5a2795d | ||
|
|
a0b250df3f | ||
|
|
321140327e | ||
|
|
be475026c8 | ||
|
|
55361c5a2b | ||
|
|
c11bf6508a | ||
|
|
195e4ca371 | ||
|
|
1206cfae37 | ||
|
|
df29650b98 | ||
|
|
94901f1662 | ||
|
|
9475ffb8c6 | ||
|
|
f3c48a3748 | ||
|
|
14031c594b | ||
|
|
74fe6ee944 | ||
|
|
a58befd6d7 | ||
|
|
38a3075a2c | ||
|
|
96dd8476ad | ||
|
|
800f5ceb6a | ||
|
|
7b19cf48a4 | ||
|
|
50f10a43dd | ||
|
|
eb66045371 | ||
|
|
a1ab87c71c | ||
|
|
8ebb61cb0e | ||
|
|
8f90613951 | ||
|
|
0888af4a6f | ||
|
|
08cd94d775 | ||
|
|
eec4126562 | ||
|
|
6acf6d544a | ||
|
|
ac7785aa7f | ||
|
|
85e73d0ee8 | ||
|
|
483f0c65b1 | ||
|
|
33c3cf3278 | ||
|
|
40210f8b00 | ||
|
|
a1057092e0 | ||
|
|
bf471abb1b | ||
|
|
c4a1f38b33 | ||
|
|
92043ded2c | ||
|
|
b059e034e2 | ||
|
|
49a59077b7 | ||
|
|
a313363ed7 | ||
|
|
ab2f8092a8 | ||
|
|
4309cf44d3 | ||
|
|
2d032259db | ||
|
|
3d09c43a91 | ||
|
|
e7054eab13 | ||
|
|
d9f36c82ba | ||
|
|
12440691c9 | ||
|
|
34aea84dde | ||
|
|
a31976586c | ||
|
|
8bdde589bb | ||
|
|
bfef13824e | ||
|
|
1a8d539c9e | ||
|
|
a120d79585 | ||
|
|
3562478946 | ||
|
|
405bc51c26 | ||
|
|
631f119061 | ||
|
|
a7d3a3940a | ||
|
|
21a4a71542 | ||
|
|
a890665775 | ||
|
|
a920914b4c | ||
|
|
47b12cc5d9 | ||
|
|
312a3f812e | ||
|
|
e3e3315125 | ||
|
|
d2ac1e152a | ||
|
|
8cc61bf577 | ||
|
|
4577f0a27f | ||
|
|
19ee5553f6 |
19
.build.yml
Normal file
19
.build.yml
Normal 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
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
/config.json
|
/config.json
|
||||||
|
|
||||||
|
.parcel-cache
|
||||||
|
/dist
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -12,6 +12,9 @@ First install dependencies:
|
|||||||
|
|
||||||
npm install --production
|
npm install --production
|
||||||
|
|
||||||
|
Then configure an HTTP server to serve the gamja files. Below are some
|
||||||
|
server-specific instructions.
|
||||||
|
|
||||||
### [soju]
|
### [soju]
|
||||||
|
|
||||||
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
|
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:
|
timeout to a value higher than the IRC server PING interval. Example:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
location / {
|
||||||
|
root /path/to/gamja;
|
||||||
|
}
|
||||||
|
|
||||||
location /socket {
|
location /socket {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
proxy_read_timeout 600s;
|
proxy_read_timeout 600s;
|
||||||
@@ -48,19 +55,26 @@ location /socket {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are unable to configure the proxy timeout accordingly, you can set the
|
If you are unable to configure the proxy timeout accordingly, or if your IRC
|
||||||
`server.ping` option in `config.json` to an interval, in seconds, between which
|
server doesn't send PINGs, you can set the `server.ping` option in
|
||||||
gamja will send opportunistic pings.
|
`config.json` (see below).
|
||||||
|
|
||||||
### Development server
|
### 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 install --include=dev
|
||||||
npm start
|
npm start -- irc.libera.chat
|
||||||
|
|
||||||
This will start a development HTTP server for gamja. Connect to it and append
|
See `npm start -- -h` for a list of options.
|
||||||
`?server=ws://localhost:8080` to the URL.
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
Optionally, [Parcel] can be used to build a minified version of gamja.
|
||||||
|
|
||||||
|
npm install --include=dev
|
||||||
|
npm run build
|
||||||
|
|
||||||
## Query parameters
|
## Query parameters
|
||||||
|
|
||||||
@@ -69,6 +83,8 @@ gamja settings can be overridden using URL query parameters:
|
|||||||
- `server`: path or URL to the WebSocket server
|
- `server`: path or URL to the WebSocket server
|
||||||
- `nick`: nickname
|
- `nick`: nickname
|
||||||
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
|
- `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
|
Alternatively, the channels can be set with the URL fragment (ie, by just
|
||||||
appending the channel name to the gamja URL).
|
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",
|
"autojoin": "#gamja",
|
||||||
// Controls how the password UI is presented to the user. Set to
|
// Controls how the password UI is presented to the user. Set to
|
||||||
// "mandatory" to require a password, "optional" to accept one but not
|
// "mandatory" to require a password, "optional" to accept one but not
|
||||||
// require it, and "disabled" to never ask for a password. Defaults to
|
// require it, "disabled" to never ask for a password, or "external" to
|
||||||
// "optional".
|
// use SASL EXTERNAL. Defaults to "optional".
|
||||||
"auth": "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
|
// Interval in seconds to send PING commands (number). Set to 0 to
|
||||||
// disable. Enabling PINGs can have an impact on client power usage and
|
// disable. Enabling PINGs can have an impact on client power usage and
|
||||||
// should only be enabled if necessary.
|
// 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
|
## Contributing
|
||||||
|
|
||||||
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
|
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
|
||||||
in #soju on Libera Chat.
|
in [#soju on Libera Chat].
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -114,3 +135,6 @@ Copyright (C) 2020 The gamja Contributors
|
|||||||
[webircgateway]: https://github.com/kiwiirc/webircgateway
|
[webircgateway]: https://github.com/kiwiirc/webircgateway
|
||||||
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
|
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
|
||||||
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
||||||
|
[Parcel]: https://parceljs.org
|
||||||
|
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
|
||||||
|
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju
|
||||||
|
|||||||
22
commands.js
22
commands.js
@@ -93,6 +93,17 @@ function givemode(app, args, mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
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": {
|
"ban": {
|
||||||
usage: "[nick]",
|
usage: "[nick]",
|
||||||
description: "Ban a user from the channel, or display the current ban list",
|
description: "Ban a user from the channel, or display the current ban list",
|
||||||
@@ -322,10 +333,11 @@ export default {
|
|||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let newRealname = args.join(" ");
|
let newRealname = args.join(" ");
|
||||||
let client = getActiveClient(app);
|
let client = getActiveClient(app);
|
||||||
if (!client.enabledCaps["setname"]) {
|
if (!client.caps.enabled.has("setname")) {
|
||||||
throw new Error("Server doesn't support changing the realname");
|
throw new Error("Server doesn't support changing the realname");
|
||||||
}
|
}
|
||||||
client.send({ command: "SETNAME", params: [newRealname] });
|
client.send({ command: "SETNAME", params: [newRealname] });
|
||||||
|
// TODO: save to local storage
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
@@ -411,4 +423,12 @@ export default {
|
|||||||
markServerBufferUnread(app);
|
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
51
components/auth-form.js
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,24 +21,12 @@ function NickStatus(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BufferHeader(props) {
|
export default function BufferHeader(props) {
|
||||||
function handleCloseClick(event) {
|
let fullyConnected = props.server.status === ServerStatus.REGISTERED;
|
||||||
event.preventDefault();
|
if (props.bouncerNetwork) {
|
||||||
props.onClose();
|
fullyConnected = fullyConnected && props.bouncerNetwork.state === "connected";
|
||||||
}
|
|
||||||
function handleJoinClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
props.onJoin();
|
|
||||||
}
|
|
||||||
function handleAddNetworkClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
props.onAddNetwork();
|
|
||||||
}
|
|
||||||
function handleManageNetworkClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
props.onManageNetwork();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let description = null, actions = null;
|
let description = null, actions = [];
|
||||||
switch (props.buffer.type) {
|
switch (props.buffer.type) {
|
||||||
case BufferType.SERVER:
|
case BufferType.SERVER:
|
||||||
switch (props.server.status) {
|
switch (props.server.status) {
|
||||||
@@ -74,56 +62,95 @@ export default function BufferHeader(props) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.isBouncer) {
|
let joinButton = html`
|
||||||
if (props.server.isupport.get("BOUNCER_NETID")) {
|
<button
|
||||||
actions = html`
|
key="join"
|
||||||
<button
|
onClick=${props.onJoin}
|
||||||
key="join"
|
>Join channel</button>
|
||||||
onClick=${handleJoinClick}
|
`;
|
||||||
>Join channel</button>
|
let reconnectButton = html`
|
||||||
<button
|
<button
|
||||||
key="manage"
|
key="reconect"
|
||||||
onClick=${handleManageNetworkClick}
|
onClick=${props.onReconnect}
|
||||||
>Manage network</button>
|
>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 {
|
} else {
|
||||||
actions = html`
|
if (fullyConnected) {
|
||||||
<button
|
actions.push(html`
|
||||||
key="add"
|
<button
|
||||||
onClick=${handleAddNetworkClick}
|
key="add"
|
||||||
>Add network</button>
|
onClick=${props.onAddNetwork}
|
||||||
|
>Add network</button>
|
||||||
|
`);
|
||||||
|
} else if (props.server.status === ServerStatus.DISCONNECTED) {
|
||||||
|
actions.push(reconnectButton);
|
||||||
|
}
|
||||||
|
actions.push(html`
|
||||||
<button
|
<button
|
||||||
key="disconnect"
|
key="disconnect"
|
||||||
class="danger"
|
class="danger"
|
||||||
onClick=${handleCloseClick}
|
onClick=${props.onClose}
|
||||||
>Disconnect</button>
|
>Disconnect</button>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
actions = html`
|
if (fullyConnected) {
|
||||||
<button
|
actions.push(joinButton);
|
||||||
key="join"
|
} else if (props.server.status === ServerStatus.DISCONNECTED) {
|
||||||
onClick=${handleJoinClick}
|
actions.push(reconnectButton);
|
||||||
>Join channel</button>
|
}
|
||||||
|
actions.push(html`
|
||||||
<button
|
<button
|
||||||
key="disconnect"
|
key="disconnect"
|
||||||
class="danger"
|
class="danger"
|
||||||
onClick=${handleCloseClick}
|
onClick=${props.onClose}
|
||||||
>Disconnect</button>
|
>Disconnect</button>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case BufferType.CHANNEL:
|
case BufferType.CHANNEL:
|
||||||
if (props.buffer.topic) {
|
if (props.buffer.topic) {
|
||||||
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
|
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
|
||||||
}
|
}
|
||||||
actions = html`
|
if (props.buffer.joined) {
|
||||||
<button
|
actions.push(html`
|
||||||
key="part"
|
<button
|
||||||
class="danger"
|
key="part"
|
||||||
onClick=${handleCloseClick}
|
class="danger"
|
||||||
>Leave</button>
|
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;
|
break;
|
||||||
case BufferType.NICK:
|
case BufferType.NICK:
|
||||||
if (props.user) {
|
if (props.user) {
|
||||||
@@ -144,9 +171,33 @@ export default function BufferHeader(props) {
|
|||||||
details.push(`${props.user.username}@${props.user.hostname}`);
|
details.push(`${props.user.username}@${props.user.hostname}`);
|
||||||
}
|
}
|
||||||
if (props.user.account) {
|
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}`;
|
description = html`<${NickStatus} status=${status}/> ${realname} ${details}`;
|
||||||
}
|
}
|
||||||
@@ -155,7 +206,7 @@ export default function BufferHeader(props) {
|
|||||||
<button
|
<button
|
||||||
key="close"
|
key="close"
|
||||||
class="danger"
|
class="danger"
|
||||||
onClick=${handleCloseClick}
|
onClick=${props.onClose}
|
||||||
>Close</button>
|
>Close</button>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
@@ -163,7 +214,7 @@ export default function BufferHeader(props) {
|
|||||||
|
|
||||||
let name = props.buffer.name;
|
let name = props.buffer.name;
|
||||||
if (props.buffer.type == BufferType.SERVER) {
|
if (props.buffer.type == BufferType.SERVER) {
|
||||||
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
|
name = getServerName(props.server, props.bouncerNetwork);
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ function BufferItem(props) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
props.onClick();
|
props.onClick();
|
||||||
}
|
}
|
||||||
|
function handleMouseDown(event) {
|
||||||
|
if (event.button === 1) { // middle click
|
||||||
|
event.preventDefault();
|
||||||
|
props.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let name = props.buffer.name;
|
let name = props.buffer.name;
|
||||||
if (props.buffer.type == BufferType.SERVER) {
|
if (props.buffer.type == BufferType.SERVER) {
|
||||||
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
|
name = getServerName(props.server, props.bouncerNetwork);
|
||||||
}
|
}
|
||||||
|
|
||||||
let classes = ["type-" + props.buffer.type];
|
let classes = ["type-" + props.buffer.type];
|
||||||
@@ -23,7 +29,11 @@ function BufferItem(props) {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li class="${classes.join(" ")}">
|
<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>
|
</li>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -34,7 +44,7 @@ export default function BufferList(props) {
|
|||||||
let server = props.servers.get(buf.server);
|
let server = props.servers.get(buf.server);
|
||||||
|
|
||||||
let bouncerNetwork = null;
|
let bouncerNetwork = null;
|
||||||
let bouncerNetID = server.isupport.get("BOUNCER_NETID");
|
let bouncerNetID = server.bouncerNetID;
|
||||||
if (bouncerNetID) {
|
if (bouncerNetID) {
|
||||||
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
|
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
|
||||||
}
|
}
|
||||||
@@ -44,9 +54,9 @@ export default function BufferList(props) {
|
|||||||
key=${buf.id}
|
key=${buf.id}
|
||||||
buffer=${buf}
|
buffer=${buf}
|
||||||
server=${server}
|
server=${server}
|
||||||
isBouncer=${props.isBouncer}
|
|
||||||
bouncerNetwork=${bouncerNetwork}
|
bouncerNetwork=${bouncerNetwork}
|
||||||
onClick=${() => props.onBufferClick(buf)}
|
onClick=${() => props.onBufferClick(buf)}
|
||||||
|
onClose=${() => props.onBufferClose(buf)}
|
||||||
active=${props.activeBuffer == buf.id}
|
active=${props.activeBuffer == buf.id}
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { html, Component } from "../lib/index.js";
|
|||||||
import linkify from "../lib/linkify.js";
|
import linkify from "../lib/linkify.js";
|
||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import { BufferType, 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";
|
import Membership from "./membership.js";
|
||||||
|
|
||||||
function djb2(s) {
|
function djb2(s) {
|
||||||
@@ -76,6 +77,8 @@ class LogLine extends Component {
|
|||||||
|
|
||||||
let onNickClick = this.props.onNickClick;
|
let onNickClick = this.props.onNickClick;
|
||||||
let onChannelClick = this.props.onChannelClick;
|
let onChannelClick = this.props.onChannelClick;
|
||||||
|
let onVerifyClick = this.props.onVerifyClick;
|
||||||
|
|
||||||
function createNick(nick) {
|
function createNick(nick) {
|
||||||
return html`
|
return html`
|
||||||
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
|
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
|
||||||
@@ -95,11 +98,11 @@ class LogLine extends Component {
|
|||||||
|
|
||||||
let lineClass = "";
|
let lineClass = "";
|
||||||
let content;
|
let content;
|
||||||
let invitee;
|
let invitee, target, account;
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case "NOTICE":
|
case "NOTICE":
|
||||||
case "PRIVMSG":
|
case "PRIVMSG":
|
||||||
let target = msg.params[0];
|
target = msg.params[0];
|
||||||
let text = msg.params[1];
|
let text = msg.params[1];
|
||||||
|
|
||||||
let ctcp = irc.parseCTCP(msg);
|
let ctcp = irc.parseCTCP(msg);
|
||||||
@@ -122,7 +125,7 @@ class LogLine extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status = null;
|
let status = null;
|
||||||
let allowedPrefixes = server.isupport.get("STATUSMSG");
|
let allowedPrefixes = server.statusMsg;
|
||||||
if (target !== buf.name && allowedPrefixes) {
|
if (target !== buf.name && allowedPrefixes) {
|
||||||
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
||||||
if (parts.name === buf.name) {
|
if (parts.name === buf.name) {
|
||||||
@@ -161,9 +164,14 @@ class LogLine extends Component {
|
|||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
case "MODE":
|
case "MODE":
|
||||||
|
target = msg.params[0];
|
||||||
content = html`
|
content = html`
|
||||||
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
|
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
|
||||||
`;
|
`;
|
||||||
|
// TODO: case-mapping
|
||||||
|
if (buf.name !== target) {
|
||||||
|
content = html`${content} on ${target}`;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
let topic = msg.params[1];
|
let topic = msg.params[1];
|
||||||
@@ -186,6 +194,10 @@ class LogLine extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case irc.RPL_WELCOME:
|
||||||
|
let nick = msg.params[0];
|
||||||
|
content = html`Connected to server, your nickname is ${nick}`;
|
||||||
|
break;
|
||||||
case irc.RPL_INVITING:
|
case irc.RPL_INVITING:
|
||||||
invitee = msg.params[1];
|
invitee = msg.params[1];
|
||||||
content = html`${createNick(invitee)} has been invited to the channel`;
|
content = html`${createNick(invitee)} has been invited to the channel`;
|
||||||
@@ -194,11 +206,59 @@ class LogLine extends Component {
|
|||||||
lineClass = "motd";
|
lineClass = "motd";
|
||||||
content = linkify(stripANSI(msg.params[1]), onChannelClick);
|
content = linkify(stripANSI(msg.params[1]), onChannelClick);
|
||||||
break;
|
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:
|
default:
|
||||||
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
||||||
lineClass = "error";
|
lineClass = "error";
|
||||||
}
|
}
|
||||||
content = html`${msg.command} ${msg.params.join(" ")}`;
|
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
@@ -266,7 +326,9 @@ class FoldGroup extends Component {
|
|||||||
return;
|
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;
|
let action;
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
@@ -286,9 +348,7 @@ class FoldGroup extends Component {
|
|||||||
content.push(", ");
|
content.push(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
let nicks = byCommand[cmd].map((msg) => msg.prefix.name);
|
content.push(createNickList([...nicks], createNick));
|
||||||
|
|
||||||
content.push(createNickList(nicks, createNick));
|
|
||||||
content.push(" " + action);
|
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 {
|
class DateSeparator extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -422,18 +558,35 @@ export default class Buffer extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
let buf = this.props.buffer;
|
let buf = this.props.buffer;
|
||||||
let server = this.props.server;
|
|
||||||
if (!buf) {
|
if (!buf) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let server = this.props.server;
|
||||||
|
let bouncerNetwork = this.props.bouncerNetwork;
|
||||||
|
let serverName = server.name;
|
||||||
|
|
||||||
let children = [];
|
let children = [];
|
||||||
if (buf.type == BufferType.SERVER) {
|
if (buf.type == BufferType.SERVER) {
|
||||||
children.push(html`<${NotificationNagger}/>`);
|
children.push(html`<${NotificationNagger}/>`);
|
||||||
}
|
}
|
||||||
|
if (buf.type == BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
|
||||||
|
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 onChannelClick = this.props.onChannelClick;
|
||||||
let onNickClick = this.props.onNickClick;
|
let onNickClick = this.props.onNickClick;
|
||||||
|
let onVerifyClick = this.props.onVerifyClick;
|
||||||
|
|
||||||
function createLogLine(msg) {
|
function createLogLine(msg) {
|
||||||
return html`
|
return html`
|
||||||
<${LogLine}
|
<${LogLine}
|
||||||
@@ -443,6 +596,7 @@ export default class Buffer extends Component {
|
|||||||
server=${server}
|
server=${server}
|
||||||
onChannelClick=${onChannelClick}
|
onChannelClick=${onChannelClick}
|
||||||
onNickClick=${onNickClick}
|
onNickClick=${onNickClick}
|
||||||
|
onVerifyClick=${onVerifyClick}
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { html, Component, createRef } from "../lib/index.js";
|
import { html, Component, createRef } from "../lib/index.js";
|
||||||
|
import linkify from "../lib/linkify.js";
|
||||||
|
|
||||||
export default class ConnectForm extends Component {
|
export default class ConnectForm extends Component {
|
||||||
state = {
|
state = {
|
||||||
@@ -9,7 +10,7 @@ export default class ConnectForm extends Component {
|
|||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
username: "",
|
username: "",
|
||||||
realname: "",
|
realname: "",
|
||||||
autojoin: "",
|
autojoin: true,
|
||||||
};
|
};
|
||||||
nickInput = createRef();
|
nickInput = createRef();
|
||||||
|
|
||||||
@@ -27,7 +28,6 @@ export default class ConnectForm extends Component {
|
|||||||
rememberMe: props.params.autoconnect || false,
|
rememberMe: props.params.autoconnect || false,
|
||||||
username: props.params.username || "",
|
username: props.params.username || "",
|
||||||
realname: props.params.realname || "",
|
realname: props.params.realname || "",
|
||||||
autojoin: (props.params.autojoin || []).join(","),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,15 +61,13 @@ export default class ConnectForm extends Component {
|
|||||||
username: params.username || params.nick,
|
username: params.username || params.nick,
|
||||||
password: this.state.password,
|
password: this.state.password,
|
||||||
};
|
};
|
||||||
|
} else if (this.props.auth === "external") {
|
||||||
|
params.saslExternal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state.autojoin.split(",").forEach(function(ch) {
|
if (this.state.autojoin) {
|
||||||
ch = ch.trim();
|
params.autojoin = this.props.params.autojoin || [];
|
||||||
if (!ch) {
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
params.autojoin.push(ch);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.props.onSubmit(params);
|
this.props.onSubmit(params);
|
||||||
}
|
}
|
||||||
@@ -107,12 +105,12 @@ export default class ConnectForm extends Component {
|
|||||||
`;
|
`;
|
||||||
} else if (this.props.error) {
|
} else if (this.props.error) {
|
||||||
status = html`
|
status = html`
|
||||||
<p class="error-text">${this.props.error}</p>
|
<p class="error-text">${linkify(this.props.error)}</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth = null;
|
let auth = null;
|
||||||
if (this.props.auth !== "disabled") {
|
if (this.props.auth !== "disabled" && this.props.auth !== "external") {
|
||||||
auth = html`
|
auth = html`
|
||||||
<label>
|
<label>
|
||||||
Password:<br/>
|
Password:<br/>
|
||||||
@@ -129,22 +127,22 @@ export default class ConnectForm extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let autojoin = html`
|
let autojoin = null;
|
||||||
<label>
|
let channels = this.props.params.autojoin || [];
|
||||||
Auto-join channels:<br/>
|
if (channels.length > 0) {
|
||||||
<input
|
let s = channels.length > 1 ? "s" : "";
|
||||||
type="text"
|
autojoin = html`
|
||||||
name="autojoin"
|
<label>
|
||||||
value=${this.state.autojoin}
|
<input
|
||||||
disabled=${disabled}
|
type="checkbox"
|
||||||
placeholder="Comma-separated list of channels"
|
name="autojoin"
|
||||||
/>
|
checked=${this.state.autojoin}
|
||||||
</label>
|
/>
|
||||||
<br/>
|
Auto-join channel${s} <strong>${channels.join(', ')}</strong>
|
||||||
`;
|
</label>
|
||||||
|
<br/><br/>
|
||||||
// Show autojoin field in advanced options, except if it's pre-filled
|
`;
|
||||||
let isAutojoinAdvanced = (this.props.params.autojoin || []).length === 0;
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
||||||
@@ -165,7 +163,7 @@ export default class ConnectForm extends Component {
|
|||||||
|
|
||||||
${auth}
|
${auth}
|
||||||
|
|
||||||
${!isAutojoinAdvanced ? [autojoin, html`<br/>`] : null}
|
${autojoin}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@@ -220,8 +218,6 @@ export default class ConnectForm extends Component {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|
||||||
${isAutojoinAdvanced ? autojoin : null}
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export default class JoinForm extends Component {
|
|||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
|
if (props.channel) {
|
||||||
|
this.state.channel = props.channel;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleChange(event) {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class MemberItem extends Component {
|
|||||||
|
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
return this.props.nick !== nextProps.nick
|
return this.props.nick !== nextProps.nick
|
||||||
|| this.props.membership !== nextProps.membership;
|
|| this.props.membership !== nextProps.membership
|
||||||
|
|| this.props.user !== nextProps.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick(event) {
|
handleClick(event) {
|
||||||
@@ -43,6 +44,7 @@ class MemberItem extends Component {
|
|||||||
|
|
||||||
let title = null;
|
let title = null;
|
||||||
let user = this.props.user;
|
let user = this.props.user;
|
||||||
|
let classes = ["nick"];
|
||||||
if (user) {
|
if (user) {
|
||||||
let mask = "";
|
let mask = "";
|
||||||
if (user.username && user.hostname) {
|
if (user.username && user.hostname) {
|
||||||
@@ -61,13 +63,18 @@ class MemberItem extends Component {
|
|||||||
if (user.account) {
|
if (user.account) {
|
||||||
title += `\nAuthenticated as ${user.account}`;
|
title += `\nAuthenticated as ${user.account}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.away) {
|
||||||
|
classes.push("away");
|
||||||
|
title += "\nAway";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href=${getNickURL(this.props.nick)}
|
href=${getNickURL(this.props.nick)}
|
||||||
class="nick"
|
class=${classes.join(" ")}
|
||||||
title=${title}
|
title=${title}
|
||||||
onClick=${this.handleClick}
|
onClick=${this.handleClick}
|
||||||
>
|
>
|
||||||
@@ -99,7 +106,8 @@ function sortMembers(a, b) {
|
|||||||
|
|
||||||
export default class MemberList extends Component {
|
export default class MemberList extends Component {
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
return this.props.members !== nextProps.members;
|
return this.props.members !== nextProps.members
|
||||||
|
|| this.props.users !== nextProps.users;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default class NetworkForm extends Component {
|
|||||||
prevParams = null;
|
prevParams = null;
|
||||||
state = {
|
state = {
|
||||||
...defaultParams,
|
...defaultParams,
|
||||||
isNew: true,
|
autojoin: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -25,8 +25,6 @@ export default class NetworkForm extends Component {
|
|||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
this.state.isNew = !props.params;
|
|
||||||
|
|
||||||
if (props.params) {
|
if (props.params) {
|
||||||
Object.keys(defaultParams).forEach((k) => {
|
Object.keys(defaultParams).forEach((k) => {
|
||||||
if (props.params[k] !== undefined) {
|
if (props.params[k] !== undefined) {
|
||||||
@@ -48,18 +46,22 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
let params = {};
|
let params = {};
|
||||||
Object.keys(defaultParams).forEach((k) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
params[k] = this.state[k];
|
params[k] = this.state[k];
|
||||||
});
|
});
|
||||||
|
|
||||||
this.props.onSubmit(params);
|
let autojoin = this.state.autojoin ? this.props.autojoin : null;
|
||||||
|
this.props.onSubmit(params, autojoin);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let removeNetwork = null;
|
let removeNetwork = null;
|
||||||
if (!this.state.isNew) {
|
if (!this.props.isNew) {
|
||||||
removeNetwork = html`
|
removeNetwork = html`
|
||||||
<button type="button" class="danger" onClick=${() => this.props.onRemove()}>
|
<button type="button" class="danger" onClick=${() => this.props.onRemove()}>
|
||||||
Remove network
|
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`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
@@ -75,6 +92,8 @@ export default class NetworkForm extends Component {
|
|||||||
</label>
|
</label>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|
||||||
|
${autojoin}
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary role="button">Advanced options</summary>
|
<summary role="button">Advanced options</summary>
|
||||||
|
|
||||||
@@ -121,7 +140,7 @@ export default class NetworkForm extends Component {
|
|||||||
${removeNetwork}
|
${removeNetwork}
|
||||||
${" "}
|
${" "}
|
||||||
<button>
|
<button>
|
||||||
${this.state.isNew ? "Add network" : "Save network"}
|
${this.props.isNew ? "Add network" : "Save network"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
|||||||
54
components/register-form.js
Normal file
54
components/register-form.js
Normal 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
45
components/verify-form.js
Normal 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
78
dev-server.js
Normal 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);
|
||||||
10
index.html
10
index.html
@@ -2,20 +2,16 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<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>
|
<title>gamja IRC client</title>
|
||||||
<link rel="stylesheet" href="./style.css">
|
<link rel="stylesheet" href="./style.css">
|
||||||
|
<script type="module" src="./main.js"></script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
<p>Unfortunately gamja requires JavaScript. Please enable it!</p>
|
<p>This application requires JavaScript. Please enable it!</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
<script type="module">
|
|
||||||
import { html, render } from "./lib/index.js";
|
|
||||||
import App from "./components/app.js";
|
|
||||||
|
|
||||||
render(html`<${App}/>`, document.body);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
579
lib/client.js
579
lib/client.js
@@ -13,16 +13,20 @@ const permanentCaps = [
|
|||||||
"labeled-response",
|
"labeled-response",
|
||||||
"message-tags",
|
"message-tags",
|
||||||
"multi-prefix",
|
"multi-prefix",
|
||||||
|
"sasl",
|
||||||
"server-time",
|
"server-time",
|
||||||
"setname",
|
"setname",
|
||||||
|
|
||||||
|
"draft/account-registration",
|
||||||
"draft/chathistory",
|
"draft/chathistory",
|
||||||
"draft/event-playback",
|
"draft/event-playback",
|
||||||
|
"draft/extended-monitor",
|
||||||
|
|
||||||
"soju.im/bouncer-networks",
|
"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
|
// WebSocket status codes
|
||||||
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
|
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
|
||||||
@@ -46,6 +50,53 @@ const WHOX_FIELDS = {
|
|||||||
let lastLabel = 0;
|
let lastLabel = 0;
|
||||||
let lastWhoxToken = 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 {
|
export default class Client extends EventTarget {
|
||||||
static Status = {
|
static Status = {
|
||||||
DISCONNECTED: "disconnected",
|
DISCONNECTED: "disconnected",
|
||||||
@@ -57,9 +108,9 @@ export default class Client extends EventTarget {
|
|||||||
status = Client.Status.DISCONNECTED;
|
status = Client.Status.DISCONNECTED;
|
||||||
serverPrefix = { name: "*" };
|
serverPrefix = { name: "*" };
|
||||||
nick = null;
|
nick = null;
|
||||||
availableCaps = {};
|
supportsCap = false;
|
||||||
enabledCaps = {};
|
caps = new irc.CapRegistry();
|
||||||
isupport = new Map();
|
isupport = new irc.Isupport();
|
||||||
|
|
||||||
ws = null;
|
ws = null;
|
||||||
params = {
|
params = {
|
||||||
@@ -69,16 +120,22 @@ export default class Client extends EventTarget {
|
|||||||
nick: null,
|
nick: null,
|
||||||
pass: null,
|
pass: null,
|
||||||
saslPlain: null,
|
saslPlain: null,
|
||||||
|
saslExternal: false,
|
||||||
bouncerNetwork: null,
|
bouncerNetwork: null,
|
||||||
};
|
};
|
||||||
|
debug = false;
|
||||||
batches = new Map();
|
batches = new Map();
|
||||||
autoReconnect = true;
|
autoReconnect = true;
|
||||||
reconnectTimeoutID = null;
|
reconnectTimeoutID = null;
|
||||||
|
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
|
||||||
pingIntervalID = null;
|
pingIntervalID = null;
|
||||||
pendingHistory = Promise.resolve(null);
|
pendingCmds = {
|
||||||
|
WHO: Promise.resolve(null),
|
||||||
|
CHATHISTORY: Promise.resolve(null),
|
||||||
|
};
|
||||||
cm = irc.CaseMapping.RFC1459;
|
cm = irc.CaseMapping.RFC1459;
|
||||||
monitored = new irc.CaseMapMap(null, 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();
|
whoxQueries = new Map();
|
||||||
|
|
||||||
constructor(params) {
|
constructor(params) {
|
||||||
@@ -102,30 +159,39 @@ export default class Client extends EventTarget {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create connection:", err);
|
console.error("Failed to create connection:", err);
|
||||||
setTimeout(() => {
|
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);
|
this.setStatus(Client.Status.DISCONNECTED);
|
||||||
}, 0);
|
}, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.ws.addEventListener("open", this.handleOpen.bind(this));
|
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) => {
|
this.ws.addEventListener("close", (event) => {
|
||||||
console.log("Connection closed (code: " + event.code + ")");
|
console.log("Connection closed (code: " + event.code + ")");
|
||||||
|
|
||||||
if (event.code !== NORMAL_CLOSURE && event.code !== GOING_AWAY) {
|
if (event.code !== NORMAL_CLOSURE && event.code !== GOING_AWAY) {
|
||||||
this.dispatchEvent(new CustomEvent("error", { detail: "Connection error" }));
|
this.dispatchError(new Error("Connection error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.setStatus(Client.Status.DISCONNECTED);
|
this.setStatus(Client.Status.DISCONNECTED);
|
||||||
this.nick = null;
|
this.nick = null;
|
||||||
this.serverPrefix = null;
|
this.serverPrefix = null;
|
||||||
this.availableCaps = {};
|
this.caps = new irc.CapRegistry();
|
||||||
this.enabledCaps = {};
|
|
||||||
this.batches = new Map();
|
this.batches = new Map();
|
||||||
this.pendingHistory = Promise.resolve(null);
|
Object.keys(this.pendingCmds).forEach((k) => {
|
||||||
this.isupport = new Map();
|
this.pendingCmds[k] = Promise.resolve(null);
|
||||||
|
});
|
||||||
|
this.isupport = new irc.Isupport();
|
||||||
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
|
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
|
||||||
|
|
||||||
if (this.autoReconnect) {
|
if (this.autoReconnect) {
|
||||||
@@ -137,11 +203,12 @@ export default class Client extends EventTarget {
|
|||||||
};
|
};
|
||||||
window.addEventListener("online", handleOnline);
|
window.addEventListener("online", handleOnline);
|
||||||
} else {
|
} 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);
|
clearTimeout(this.reconnectTimeoutID);
|
||||||
this.reconnectTimeoutID = setTimeout(() => {
|
this.reconnectTimeoutID = setTimeout(() => {
|
||||||
this.reconnect();
|
this.reconnect();
|
||||||
}, RECONNECT_DELAY_SEC * 1000);
|
}, delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -168,10 +235,16 @@ export default class Client extends EventTarget {
|
|||||||
this.dispatchEvent(new CustomEvent("status"));
|
this.dispatchEvent(new CustomEvent("status"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatchError(err) {
|
||||||
|
this.dispatchEvent(new CustomEvent("error", { detail: err }));
|
||||||
|
}
|
||||||
|
|
||||||
handleOpen() {
|
handleOpen() {
|
||||||
console.log("Connection opened");
|
console.log("Connection opened");
|
||||||
this.setStatus(Client.Status.REGISTERING);
|
this.setStatus(Client.Status.REGISTERING);
|
||||||
|
|
||||||
|
this.reconnectBackoff.reset();
|
||||||
|
|
||||||
this.nick = this.params.nick;
|
this.nick = this.params.nick;
|
||||||
|
|
||||||
this.send({ command: "CAP", params: ["LS", "302"] });
|
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) {
|
handleMessage(event) {
|
||||||
if (typeof event.data !== "string") {
|
if (typeof event.data !== "string") {
|
||||||
console.error("Received unsupported data type:", event.data);
|
console.error("Received unsupported data type:", event.data);
|
||||||
@@ -193,7 +280,9 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let msg = irc.parseMessage(event.data);
|
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
|
// If the prefix is missing, assume it's coming from the server on the
|
||||||
// other end of the connection
|
// other end of the connection
|
||||||
@@ -210,10 +299,11 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let deleteBatch = null;
|
let deleteBatch = null;
|
||||||
|
let k;
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case irc.RPL_WELCOME:
|
case irc.RPL_WELCOME:
|
||||||
if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
|
if (this.params.saslPlain && !this.supportsCap) {
|
||||||
this.dispatchEvent(new CustomEvent("error", { detail: "Server doesn't support SASL PLAIN" }));
|
this.dispatchError(new Error("Server doesn't support SASL PLAIN"));
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -221,34 +311,42 @@ export default class Client extends EventTarget {
|
|||||||
if (msg.prefix) {
|
if (msg.prefix) {
|
||||||
this.serverPrefix = msg.prefix;
|
this.serverPrefix = msg.prefix;
|
||||||
}
|
}
|
||||||
|
this.nick = msg.params[0];
|
||||||
|
|
||||||
console.log("Registration complete");
|
console.log("Registration complete");
|
||||||
this.setStatus(Client.Status.REGISTERED);
|
this.setStatus(Client.Status.REGISTERED);
|
||||||
break;
|
break;
|
||||||
case irc.RPL_ISUPPORT:
|
case irc.RPL_ISUPPORT:
|
||||||
|
let prevMaxMonitorTargets = this.isupport.monitor();
|
||||||
|
|
||||||
let tokens = msg.params.slice(1, -1);
|
let tokens = msg.params.slice(1, -1);
|
||||||
let changed = irc.parseISUPPORT(tokens, this.isupport);
|
this.isupport.parse(tokens);
|
||||||
if (changed.indexOf("CASEMAPPING") >= 0) {
|
this.updateCaseMapping();
|
||||||
this.setCaseMapping(this.isupport.get("CASEMAPPING"));
|
|
||||||
}
|
let maxMonitorTargets = this.isupport.monitor();
|
||||||
if (changed.indexOf("MONITOR") >= 0 && this.isupport.has("MONITOR") && this.monitored.size > 0) {
|
if (prevMaxMonitorTargets === 0 && this.monitored.size > 0 && maxMonitorTargets > 0) {
|
||||||
let targets = Array.from(this.monitored.keys()).slice(0, this.maxMonitorTargets());
|
let targets = Array.from(this.monitored.keys()).slice(0, maxMonitorTargets);
|
||||||
this.send({ command: "MONITOR", params: ["+", targets.join(",")] });
|
this.send({ command: "MONITOR", params: ["+", targets.join(",")] });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case irc.RPL_ENDOFMOTD:
|
case irc.RPL_ENDOFMOTD:
|
||||||
case irc.ERR_NOMOTD:
|
case irc.ERR_NOMOTD:
|
||||||
// These messages are used to indicate the end of the ISUPPORT list
|
// 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
|
// Server didn't send any CASEMAPPING token, assume RFC 1459
|
||||||
this.setCaseMapping("rfc1459");
|
this.updateCaseMapping();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "CAP":
|
case "CAP":
|
||||||
this.handleCap(msg);
|
this.handleCap(msg);
|
||||||
break;
|
break;
|
||||||
case "AUTHENTICATE":
|
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;
|
break;
|
||||||
case irc.RPL_LOGGEDIN:
|
case irc.RPL_LOGGEDIN:
|
||||||
console.log("Logged in");
|
console.log("Logged in");
|
||||||
@@ -256,41 +354,35 @@ export default class Client extends EventTarget {
|
|||||||
case irc.RPL_LOGGEDOUT:
|
case irc.RPL_LOGGEDOUT:
|
||||||
console.log("Logged out");
|
console.log("Logged out");
|
||||||
break;
|
break;
|
||||||
case irc.RPL_SASLSUCCESS:
|
case irc.RPL_NAMREPLY:
|
||||||
console.log("SASL authentication success");
|
this.pushPendingList("NAMES " + msg.params[2], msg);
|
||||||
if (this.status != Client.Status.REGISTERED) {
|
break;
|
||||||
if (this.enabledCaps["soju.im/bouncer-networks"] && this.params.bouncerNetwork) {
|
case irc.RPL_ENDOFNAMES:
|
||||||
this.send({ command: "BOUNCER", params: ["BIND", this.params.bouncerNetwork] });
|
this.endPendingList("NAMES " + msg.params[1], msg);
|
||||||
}
|
|
||||||
this.send({ command: "CAP", params: ["END"] });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case irc.RPL_WHOISUSER:
|
case irc.RPL_WHOISUSER:
|
||||||
case irc.RPL_WHOISSERVER:
|
case irc.RPL_WHOISSERVER:
|
||||||
case irc.RPL_WHOISOPERATOR:
|
case irc.RPL_WHOISOPERATOR:
|
||||||
case irc.RPL_WHOISIDLE:
|
case irc.RPL_WHOISIDLE:
|
||||||
case irc.RPL_WHOISCHANNELS:
|
case irc.RPL_WHOISCHANNELS:
|
||||||
case irc.RPL_ENDOFWHOIS:
|
this.pushPendingList("WHOIS " + msg.params[1], msg);
|
||||||
let nick = msg.params[1];
|
|
||||||
if (!this.whoisDB.has(nick)) {
|
|
||||||
this.whoisDB.set(nick, {});
|
|
||||||
}
|
|
||||||
this.whoisDB.get(nick)[msg.command] = msg;
|
|
||||||
break;
|
break;
|
||||||
case irc.ERR_NICKLOCKED:
|
case irc.RPL_ENDOFWHOIS:
|
||||||
case irc.ERR_SASLFAIL:
|
this.endPendingList("WHOIS " + msg.params[1], msg);
|
||||||
case irc.ERR_SASLTOOLONG:
|
break;
|
||||||
case irc.ERR_SASLABORTED:
|
case irc.RPL_WHOREPLY:
|
||||||
case irc.ERR_SASLALREADY:
|
case irc.RPL_WHOSPCRPL:
|
||||||
this.dispatchEvent(new CustomEvent("error", { detail: "SASL error (" + msg.command + "): " + msg.params[1] }));
|
this.pushPendingList("WHO", msg);
|
||||||
this.disconnect();
|
break;
|
||||||
|
case irc.RPL_ENDOFWHO:
|
||||||
|
this.endPendingList("WHO", msg);
|
||||||
break;
|
break;
|
||||||
case "PING":
|
case "PING":
|
||||||
this.send({ command: "PONG", params: [msg.params[0]] });
|
this.send({ command: "PONG", params: [msg.params[0]] });
|
||||||
break;
|
break;
|
||||||
case "NICK":
|
case "NICK":
|
||||||
let newNick = msg.params[0];
|
let newNick = msg.params[0];
|
||||||
if (msg.prefix.name == this.nick) {
|
if (this.isMyNick(msg.prefix.name)) {
|
||||||
this.nick = newNick;
|
this.nick = newNick;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -311,7 +403,7 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
this.dispatchEvent(new CustomEvent("error", { detail: "Fatal IRC error: " + msg.params[0] }));
|
this.dispatchError(new IRCError(msg));
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
break;
|
break;
|
||||||
case irc.ERR_PASSWDMISMATCH:
|
case irc.ERR_PASSWDMISMATCH:
|
||||||
@@ -321,18 +413,26 @@ export default class Client extends EventTarget {
|
|||||||
case irc.ERR_UNAVAILRESOURCE:
|
case irc.ERR_UNAVAILRESOURCE:
|
||||||
case irc.ERR_NOPERMFORHOST:
|
case irc.ERR_NOPERMFORHOST:
|
||||||
case irc.ERR_YOUREBANNEDCREEP:
|
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) {
|
if (this.status != Client.Status.REGISTERED) {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "FAIL":
|
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") {
|
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
|
||||||
this.dispatchEvent(new CustomEvent("error", {
|
this.dispatchError(new Error("Failed to bind to bouncer network", {
|
||||||
detail: "Failed to bind to bouncer network: " + msg.params[3],
|
cause: new IRCError(msg),
|
||||||
}));
|
}));
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
}
|
}
|
||||||
|
if (msg.params[1] === "ACCOUNT_REQUIRED") {
|
||||||
|
this.dispatchError(new IRCError(msg));
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
break;
|
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) {
|
who(mask, options) {
|
||||||
let params = [mask];
|
let params = [mask];
|
||||||
|
|
||||||
let fields = "", token = "";
|
let fields = "", token = "";
|
||||||
if (options && this.isupport.has("WHOX")) {
|
if (options && this.isupport.whox()) {
|
||||||
let match = ""; // Matches exact channel or nick
|
let match = ""; // Matches exact channel or nick
|
||||||
|
|
||||||
fields = "t"; // Always include token in reply
|
fields = "t"; // Always include token in reply
|
||||||
@@ -373,27 +510,30 @@ export default class Client extends EventTarget {
|
|||||||
|
|
||||||
let msg = { command: "WHO", params };
|
let msg = { command: "WHO", params };
|
||||||
let l = [];
|
let l = [];
|
||||||
return this.roundtrip(msg, (msg) => {
|
let promise = this.pendingCmds.WHO.then(() => {
|
||||||
switch (msg.command) {
|
return this.roundtrip(msg, (msg) => {
|
||||||
case irc.RPL_WHOREPLY:
|
switch (msg.command) {
|
||||||
// TODO: match with mask
|
case irc.RPL_WHOREPLY:
|
||||||
l.push(this.parseWhoReply(msg));
|
l.push(this.parseWhoReply(msg));
|
||||||
break;
|
break;
|
||||||
case irc.RPL_WHOSPCRPL:
|
case irc.RPL_WHOSPCRPL:
|
||||||
if (msg.params.length !== fields.length || msg.params[1] !== token) {
|
if (msg.params.length !== fields.length || msg.params[1] !== token) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
l.push(this.parseWhoReply(msg));
|
||||||
|
break;
|
||||||
|
case irc.RPL_ENDOFWHO:
|
||||||
|
if (msg.params[1] === mask) {
|
||||||
|
return l;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
l.push(this.parseWhoReply(msg));
|
}).finally(() => {
|
||||||
break;
|
this.whoxQueries.delete(token);
|
||||||
case irc.RPL_ENDOFWHO:
|
});
|
||||||
if (msg.params[1] === mask) {
|
|
||||||
return l;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}).finally(() => {
|
|
||||||
this.whoxQueries.delete(token);
|
|
||||||
});
|
});
|
||||||
|
this.pendingCmds.WHO = promise.catch(() => {});
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
parseWhoReply(msg) {
|
parseWhoReply(msg) {
|
||||||
@@ -424,6 +564,10 @@ export default class Client extends EventTarget {
|
|||||||
who[k] = msg.params[2 + i];
|
who[k] = msg.params[2 + i];
|
||||||
i++;
|
i++;
|
||||||
});
|
});
|
||||||
|
if (who.account === "0") {
|
||||||
|
// WHOX uses "0" to mean "no account"
|
||||||
|
who.account = null;
|
||||||
|
}
|
||||||
return who;
|
return who;
|
||||||
default:
|
default:
|
||||||
throw new Error("Not a WHO reply: " + msg.command);
|
throw new Error("Not a WHO reply: " + msg.command);
|
||||||
@@ -439,146 +583,120 @@ export default class Client extends EventTarget {
|
|||||||
case irc.RPL_ENDOFWHOIS:
|
case irc.RPL_ENDOFWHOIS:
|
||||||
nick = msg.params[1];
|
nick = msg.params[1];
|
||||||
if (this.cm(nick) === targetCM) {
|
if (this.cm(nick) === targetCM) {
|
||||||
return this.whoisDB.get(nick);
|
let whois = {};
|
||||||
|
msg.list.forEach((reply) => {
|
||||||
|
whois[reply.command] = reply;
|
||||||
|
});
|
||||||
|
return whois;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case irc.ERR_NOSUCHNICK:
|
case irc.ERR_NOSUCHNICK:
|
||||||
nick = msg.params[1];
|
nick = msg.params[1];
|
||||||
if (this.cm(nick) === targetCM) {
|
if (this.cm(nick) === targetCM) {
|
||||||
throw msg;
|
throw new IRCError(msg);
|
||||||
}
|
}
|
||||||
break;
|
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) {
|
supportsSASL(mech) {
|
||||||
let saslCap = this.availableCaps["sasl"];
|
let saslCap = this.caps.available.get("sasl");
|
||||||
if (saslCap === undefined) {
|
if (saslCap === undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return saslCap.split(",").includes(mech);
|
return saslCap.split(",").includes(mech);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestCaps(extra) {
|
checkAccountRegistrationCap(k) {
|
||||||
let reqCaps = extra || [];
|
let v = this.caps.available.get("draft/account-registration");
|
||||||
|
if (v === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return v.split(",").includes(k);
|
||||||
|
}
|
||||||
|
|
||||||
permanentCaps.forEach((cap) => {
|
requestCaps() {
|
||||||
if (this.availableCaps[cap] !== undefined && !this.enabledCaps[cap]) {
|
let wantCaps = [].concat(permanentCaps);
|
||||||
reqCaps.push(cap);
|
if (!this.params.bouncerNetwork) {
|
||||||
}
|
wantCaps.push("soju.im/bouncer-networks-notify");
|
||||||
});
|
}
|
||||||
|
|
||||||
if (reqCaps.length > 0) {
|
let msg = this.caps.requestAvailable(wantCaps);
|
||||||
this.send({ command: "CAP", params: ["REQ", reqCaps.join(" ")] });
|
if (msg) {
|
||||||
|
this.send(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCap(msg) {
|
handleCap(msg) {
|
||||||
|
this.caps.parse(msg);
|
||||||
|
|
||||||
let subCmd = msg.params[1];
|
let subCmd = msg.params[1];
|
||||||
let args = msg.params.slice(2);
|
let args = msg.params.slice(2);
|
||||||
switch (subCmd) {
|
switch (subCmd) {
|
||||||
case "LS":
|
case "LS":
|
||||||
this.addAvailableCaps(args[args.length - 1]);
|
this.supportsCap = true;
|
||||||
if (args[0] != "*") {
|
if (args[0] == "*") {
|
||||||
console.log("Available server caps:", this.availableCaps);
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let reqCaps = [];
|
console.log("Available server caps:", this.caps.available);
|
||||||
let capEnd = true;
|
|
||||||
if (this.params.saslPlain && this.supportsSASL("PLAIN")) {
|
this.requestCaps();
|
||||||
// CAP END is deferred after authentication finishes
|
|
||||||
reqCaps.push("sasl");
|
if (this.status !== Client.Status.REGISTERED) {
|
||||||
capEnd = false;
|
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) {
|
if (this.caps.available.has("soju.im/bouncer-networks") && this.params.bouncerNetwork) {
|
||||||
reqCaps.push("soju.im/bouncer-networks-notify");
|
this.send({ command: "BOUNCER", params: ["BIND", this.params.bouncerNetwork] });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requestCaps(reqCaps);
|
this.send({ command: "CAP", params: ["END"] });
|
||||||
|
|
||||||
if (this.status != Client.Status.REGISTERED && capEnd) {
|
|
||||||
this.send({ command: "CAP", params: ["END"] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "NEW":
|
case "NEW":
|
||||||
this.addAvailableCaps(args[0]);
|
|
||||||
console.log("Server added available caps:", args[0]);
|
console.log("Server added available caps:", args[0]);
|
||||||
this.requestCaps();
|
this.requestCaps();
|
||||||
break;
|
break;
|
||||||
case "DEL":
|
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]);
|
console.log("Server removed available caps:", args[0]);
|
||||||
break;
|
break;
|
||||||
case "ACK":
|
case "ACK":
|
||||||
console.log("Server ack'ed caps:", args[0]);
|
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;
|
break;
|
||||||
case "NAK":
|
case "NAK":
|
||||||
console.log("Server nak'ed caps:", args[0]);
|
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"] });
|
this.send({ command: "CAP", params: ["END"] });
|
||||||
}
|
}
|
||||||
break;
|
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) {
|
send(msg) {
|
||||||
if (!this.ws) {
|
if (!this.ws) {
|
||||||
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
|
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
|
||||||
}
|
}
|
||||||
this.ws.send(irc.formatMessage(msg));
|
this.ws.send(irc.formatMessage(msg));
|
||||||
console.debug("Sent:", msg);
|
if (this.debug) {
|
||||||
|
console.debug("Sent:", msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCaseMapping(name) {
|
updateCaseMapping() {
|
||||||
this.cm = irc.CaseMapping.byName(name);
|
this.cm = this.isupport.caseMapping();
|
||||||
if (!this.cm) {
|
this.pendingLists = new irc.CaseMapMap(this.pendingLists, 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);
|
|
||||||
this.monitored = new irc.CaseMapMap(this.monitored, this.cm);
|
this.monitored = new irc.CaseMapMap(this.monitored, this.cm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +709,7 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isChannel(name) {
|
isChannel(name) {
|
||||||
let chanTypes = this.isupport.get("CHANTYPES") || irc.STD_CHANTYPES;
|
let chanTypes = this.isupport.chanTypes();
|
||||||
return chanTypes.indexOf(name[0]) >= 0;
|
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
|
/* Execute a command that expects a response. `done` is called with message
|
||||||
* events until it returns a truthy value. */
|
* events until it returns a truthy value. */
|
||||||
roundtrip(msg, done) {
|
roundtrip(msg, done) {
|
||||||
|
let cmd = msg.command;
|
||||||
|
|
||||||
let label;
|
let label;
|
||||||
if (this.enabledCaps["labeled-response"]) {
|
if (this.caps.enabled.has("labeled-response")) {
|
||||||
lastLabel++;
|
lastLabel++;
|
||||||
label = String(lastLabel);
|
label = String(lastLabel);
|
||||||
msg.tags = { ...msg.tags, label };
|
msg.tags = { ...msg.tags, label };
|
||||||
@@ -631,6 +751,24 @@ export default class Client extends EventTarget {
|
|||||||
return;
|
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;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = done(msg);
|
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) {
|
fetchBatch(msg, batchType) {
|
||||||
let batchName = null;
|
let batchName = null;
|
||||||
let messages = [];
|
let messages = [];
|
||||||
|
let cmd = msg.command;
|
||||||
return this.roundtrip(msg, (msg) => {
|
return this.roundtrip(msg, (msg) => {
|
||||||
if (batchName) {
|
if (batchName) {
|
||||||
let batch = msg.batch;
|
let batch = msg.batch;
|
||||||
@@ -679,23 +844,18 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (msg.command) {
|
if (msg.command !== "BATCH") {
|
||||||
case "BATCH":
|
return;
|
||||||
let enter = msg.params[0].startsWith("+");
|
}
|
||||||
let name = msg.params[0].slice(1);
|
|
||||||
if (enter && msg.params[1] === batchType) {
|
let enter = msg.params[0].startsWith("+");
|
||||||
batchName = name;
|
let name = msg.params[0].slice(1);
|
||||||
break;
|
if (enter && msg.params[1] === batchType) {
|
||||||
}
|
batchName = name;
|
||||||
if (!enter && name === batchName) {
|
return;
|
||||||
return { ...this.batches.get(name), messages };
|
}
|
||||||
}
|
if (!enter && name === batchName) {
|
||||||
break;
|
return { ...this.batches.get(name), messages };
|
||||||
case "FAIL":
|
|
||||||
if (msg.params[0] === msg.command) {
|
|
||||||
throw msg;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -703,29 +863,20 @@ export default class Client extends EventTarget {
|
|||||||
roundtripChatHistory(params) {
|
roundtripChatHistory(params) {
|
||||||
// Don't send multiple CHATHISTORY commands in parallel, we can't
|
// Don't send multiple CHATHISTORY commands in parallel, we can't
|
||||||
// properly handle batches and errors.
|
// properly handle batches and errors.
|
||||||
this.pendingHistory = this.pendingHistory.catch(() => {}).then(() => {
|
let promise = this.pendingCmds.CHATHISTORY.then(() => {
|
||||||
let msg = {
|
let msg = {
|
||||||
command: "CHATHISTORY",
|
command: "CHATHISTORY",
|
||||||
params,
|
params,
|
||||||
};
|
};
|
||||||
return this.fetchBatch(msg, "chathistory").then((batch) => batch.messages);
|
return this.fetchBatch(msg, "chathistory").then((batch) => batch.messages);
|
||||||
});
|
});
|
||||||
return this.pendingHistory;
|
this.pendingCmds.CHATHISTORY = promise.catch(() => {});
|
||||||
}
|
return promise;
|
||||||
|
|
||||||
chatHistoryPageSize() {
|
|
||||||
if (this.isupport.has("CHATHISTORY")) {
|
|
||||||
let pageSize = parseInt(this.isupport.get("CHATHISTORY"), 10);
|
|
||||||
if (pageSize > 0) {
|
|
||||||
return pageSize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fetch one page of history before the given date. */
|
/* Fetch one page of history before the given date. */
|
||||||
fetchHistoryBefore(target, before, limit) {
|
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];
|
let params = ["BEFORE", target, "timestamp=" + before, max];
|
||||||
return this.roundtripChatHistory(params).then((messages) => {
|
return this.roundtripChatHistory(params).then((messages) => {
|
||||||
return { more: messages.length >= max };
|
return { more: messages.length >= max };
|
||||||
@@ -734,7 +885,7 @@ export default class Client extends EventTarget {
|
|||||||
|
|
||||||
/* Fetch history in ascending order. */
|
/* Fetch history in ascending order. */
|
||||||
fetchHistoryBetween(target, after, before, limit) {
|
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];
|
let params = ["AFTER", target, "timestamp=" + after.time, max];
|
||||||
return this.roundtripChatHistory(params).then((messages) => {
|
return this.roundtripChatHistory(params).then((messages) => {
|
||||||
limit -= messages.length;
|
limit -= messages.length;
|
||||||
@@ -769,10 +920,6 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listBouncerNetworks() {
|
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"] };
|
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
|
||||||
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
|
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
|
||||||
let networks = new Map();
|
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) {
|
monitor(target) {
|
||||||
if (this.monitored.has(target)) {
|
if (this.monitored.has(target)) {
|
||||||
return;
|
return;
|
||||||
@@ -801,7 +941,7 @@ export default class Client extends EventTarget {
|
|||||||
this.monitored.set(target, true);
|
this.monitored.set(target, true);
|
||||||
|
|
||||||
// TODO: add poll-based fallback when MONITOR is not supported
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,10 +955,53 @@ export default class Client extends EventTarget {
|
|||||||
|
|
||||||
this.monitored.delete(target);
|
this.monitored.delete(target);
|
||||||
|
|
||||||
if (!this.isupport.has("MONITOR")) {
|
if (this.isupport.monitor() <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.send({ command: "MONITOR", params: ["-", target] });
|
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] };
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import { h } from "../node_modules/preact/dist/preact.module.js";
|
|||||||
import htm from "../node_modules/htm/dist/htm.module.js";
|
import htm from "../node_modules/htm/dist/htm.module.js";
|
||||||
export const html = htm.bind(h);
|
export const html = htm.bind(h);
|
||||||
|
|
||||||
import "../node_modules/anchorme/dist/browser/anchorme.min.js";
|
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js";
|
||||||
export const anchorme = window.anchorme;
|
export { linkifyjs };
|
||||||
|
|||||||
285
lib/irc.js
285
lib/irc.js
@@ -4,6 +4,9 @@ export const RPL_YOURHOST = "002";
|
|||||||
export const RPL_CREATED = "003";
|
export const RPL_CREATED = "003";
|
||||||
export const RPL_MYINFO = "004";
|
export const RPL_MYINFO = "004";
|
||||||
export const RPL_ISUPPORT = "005";
|
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_WHOISUSER = "311";
|
||||||
export const RPL_WHOISSERVER = "312";
|
export const RPL_WHOISSERVER = "312";
|
||||||
export const RPL_WHOISOPERATOR = "313";
|
export const RPL_WHOISOPERATOR = "313";
|
||||||
@@ -12,7 +15,6 @@ export const RPL_ENDOFWHOIS = "318";
|
|||||||
export const RPL_WHOISCHANNELS = "319";
|
export const RPL_WHOISCHANNELS = "319";
|
||||||
export const RPL_ENDOFWHO = "315";
|
export const RPL_ENDOFWHO = "315";
|
||||||
export const RPL_CHANNELMODEIS = "324";
|
export const RPL_CHANNELMODEIS = "324";
|
||||||
export const RPL_CREATIONTIME = "329";
|
|
||||||
export const RPL_NOTOPIC = "331";
|
export const RPL_NOTOPIC = "331";
|
||||||
export const RPL_TOPIC = "332";
|
export const RPL_TOPIC = "332";
|
||||||
export const RPL_TOPICWHOTIME = "333";
|
export const RPL_TOPICWHOTIME = "333";
|
||||||
@@ -30,17 +32,27 @@ export const RPL_ENDOFBANLIST = "368";
|
|||||||
export const RPL_MOTD = "372";
|
export const RPL_MOTD = "372";
|
||||||
export const RPL_MOTDSTART = "375";
|
export const RPL_MOTDSTART = "375";
|
||||||
export const RPL_ENDOFMOTD = "376";
|
export const RPL_ENDOFMOTD = "376";
|
||||||
|
export const ERR_UNKNOWNERROR = "400";
|
||||||
export const ERR_NOSUCHNICK = "401";
|
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_NOMOTD = "422";
|
||||||
export const ERR_ERRONEUSNICKNAME = "432";
|
export const ERR_ERRONEUSNICKNAME = "432";
|
||||||
export const ERR_NICKNAMEINUSE = "433";
|
export const ERR_NICKNAMEINUSE = "433";
|
||||||
export const ERR_NICKCOLLISION = "436";
|
export const ERR_NICKCOLLISION = "436";
|
||||||
|
export const ERR_NEEDMOREPARAMS = "461";
|
||||||
export const ERR_NOPERMFORHOST = "463";
|
export const ERR_NOPERMFORHOST = "463";
|
||||||
export const ERR_PASSWDMISMATCH = "464";
|
export const ERR_PASSWDMISMATCH = "464";
|
||||||
export const ERR_YOUREBANNEDCREEP = "465";
|
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
|
// RFC 2812
|
||||||
export const ERR_UNAVAILRESOURCE = "437";
|
export const ERR_UNAVAILRESOURCE = "437";
|
||||||
// Other
|
// Other
|
||||||
|
export const RPL_CREATIONTIME = "329";
|
||||||
export const RPL_QUIETLIST = "728";
|
export const RPL_QUIETLIST = "728";
|
||||||
export const RPL_ENDOFQUIETLIST = "729";
|
export const RPL_ENDOFQUIETLIST = "729";
|
||||||
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
|
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
|
||||||
@@ -121,32 +133,32 @@ export function parsePrefix(s) {
|
|||||||
host: null,
|
host: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let host = null;
|
||||||
let i = s.indexOf("@");
|
let i = s.indexOf("@");
|
||||||
if (i < 0) {
|
if (i > 0) {
|
||||||
prefix.name = s;
|
host = s.slice(i + 1);
|
||||||
return prefix;
|
s = s.slice(0, i);
|
||||||
}
|
}
|
||||||
prefix.host = s.slice(i + 1);
|
|
||||||
s = s.slice(0, i);
|
|
||||||
|
|
||||||
|
let user = null;
|
||||||
i = s.indexOf("!");
|
i = s.indexOf("!");
|
||||||
if (i < 0) {
|
if (i > 0) {
|
||||||
prefix.name = s;
|
user = s.slice(i + 1);
|
||||||
return prefix;
|
s = s.slice(0, i);
|
||||||
}
|
}
|
||||||
prefix.name = s.slice(0, i);
|
|
||||||
prefix.user = s.slice(i + 1);
|
return { name: s, user, host };
|
||||||
return prefix;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrefix(prefix) {
|
function formatPrefix(prefix) {
|
||||||
if (!prefix.host) {
|
let s = prefix.name;
|
||||||
return prefix.name;
|
if (prefix.user) {
|
||||||
|
s += "!" + prefix.user;
|
||||||
}
|
}
|
||||||
if (!prefix.user) {
|
if (prefix.host) {
|
||||||
return prefix.name + "@" + prefix.host;
|
s += "@" + prefix.host;
|
||||||
}
|
}
|
||||||
return prefix.name + "!" + prefix.user + "@" + prefix.host;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMessage(s) {
|
export function parseMessage(s) {
|
||||||
@@ -363,28 +375,95 @@ export function parseCTCP(msg) {
|
|||||||
return ctcp;
|
return ctcp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseISUPPORT(tokens, params) {
|
function unescapeISUPPORTValue(s) {
|
||||||
let changed = [];
|
return s.replace(/\\x[0-9A-Z]{2}/gi, (esc) => {
|
||||||
tokens.forEach((tok) => {
|
let hex = esc.slice(2);
|
||||||
if (tok.startsWith("-")) {
|
return String.fromCharCode(parseInt(hex, 16));
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
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 = {
|
export const CaseMapping = {
|
||||||
@@ -593,8 +672,8 @@ export function getMessageLabel(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function forEachChannelModeUpdate(msg, isupport, callback) {
|
export function forEachChannelModeUpdate(msg, isupport, callback) {
|
||||||
let chanmodes = isupport.get("CHANMODES") || STD_CHANMODES;
|
let chanmodes = isupport.chanModes();
|
||||||
let prefix = isupport.get("PREFIX") || "";
|
let prefix = isupport.prefix();
|
||||||
|
|
||||||
let typeByMode = new Map();
|
let typeByMode = new Map();
|
||||||
let [a, b, c, d] = chanmodes.split(",");
|
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.
|
* Since the realname is mandatory, many clients set a meaningless realname.
|
||||||
*/
|
*/
|
||||||
export function isMeaningfulRealname(realname, nick) {
|
export function isMeaningfulRealname(realname, nick) {
|
||||||
@@ -647,7 +728,7 @@ export function isMeaningfulRealname(realname, nick) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (realname.toLowerCase() === "realname") {
|
if (realname.toLowerCase() === "realname" || realname.toLowerCase() === "unknown" || realname.toLowerCase() === "fullname") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,3 +736,125 @@ export function isMeaningfulRealname(realname, nick) {
|
|||||||
|
|
||||||
return true;
|
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(" ")] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,65 +1,68 @@
|
|||||||
import { anchorme, html } from "./index.js";
|
import { linkifyjs, html } from "./index.js";
|
||||||
|
|
||||||
function linkifyChannel(text, transformChannel) {
|
linkifyjs.options.defaults.defaultProtocol = "https";
|
||||||
// Don't match punctuation at the end of the channel name
|
|
||||||
const channelRegex = /(^|\s)(#[^\s]+[^\s.?!…():;,])/gi;
|
|
||||||
|
|
||||||
let children = [];
|
linkifyjs.registerCustomProtocol("irc");
|
||||||
let match;
|
linkifyjs.registerCustomProtocol("ircs");
|
||||||
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;
|
|
||||||
|
|
||||||
children.push(text.substring(last, start));
|
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser, utils }) => {
|
||||||
children.push(transformChannel(channel));
|
const { POUND, DOMAIN, TLD, LOCALHOST, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
|
||||||
|
const START_STATE = parser.start;
|
||||||
|
|
||||||
last = end;
|
const Channel = utils.createTokenClass("ircChannel", {
|
||||||
}
|
isLink: true,
|
||||||
children.push(text.substring(last));
|
toHref() {
|
||||||
|
return "irc:///" + this.toString();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return children;
|
const HASH_STATE = START_STATE.tt(POUND);
|
||||||
}
|
|
||||||
|
|
||||||
export default function linkify(text, onChannelClick) {
|
const CHAN_STATE = HASH_STATE.tt(DOMAIN, Channel);
|
||||||
function transformChannel(channel) {
|
HASH_STATE.tt(TLD, CHAN_STATE);
|
||||||
function onClick(event) {
|
HASH_STATE.tt(LOCALHOST, CHAN_STATE);
|
||||||
event.preventDefault();
|
HASH_STATE.tt(POUND, CHAN_STATE);
|
||||||
onChannelClick(channel);
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<a
|
|
||||||
href="irc:///${encodeURIComponent(channel)}"
|
|
||||||
onClick=${onClick}
|
|
||||||
>${channel}</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 children = [];
|
||||||
let last = 0;
|
let last = 0;
|
||||||
links.forEach((match) => {
|
links.forEach((match) => {
|
||||||
|
if (!match.isLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const prefix = text.substring(last, match.start)
|
const prefix = text.substring(last, match.start)
|
||||||
children.push(...linkifyChannel(prefix, transformChannel));
|
children.push(prefix);
|
||||||
|
|
||||||
let proto = match.protocol || "https://";
|
children.push(html`
|
||||||
if (match.isEmail) {
|
<a
|
||||||
proto = "mailto:";
|
href=${match.href}
|
||||||
}
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
let url = match.string;
|
onClick=${onClick}
|
||||||
if (!url.startsWith(proto)) {
|
>${match.value}</a>
|
||||||
url = proto + url;
|
`);
|
||||||
}
|
|
||||||
|
|
||||||
children.push(html`<a href=${url} target="_blank" rel="noreferrer noopener">${match.string}</a>`);
|
|
||||||
|
|
||||||
last = match.end;
|
last = match.end;
|
||||||
});
|
});
|
||||||
|
|
||||||
const suffix = text.substring(last)
|
const suffix = text.substring(last)
|
||||||
children.push(...linkifyChannel(suffix, transformChannel));
|
children.push(suffix);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
4
main.js
Normal file
4
main.js
Normal 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
14249
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,15 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "gamja",
|
"name": "gamja",
|
||||||
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"anchorme": "^2.1.2",
|
|
||||||
"htm": "^3.0.4",
|
"htm": "^3.0.4",
|
||||||
|
"linkifyjs": "^3.0.2",
|
||||||
"preact": "^10.5.9"
|
"preact": "^10.5.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": {
|
"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
148
state.js
@@ -63,20 +63,26 @@ export function getMessageURL(buf, msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServerName(server, bouncerNetwork, isBouncer) {
|
export function getServerName(server, bouncerNetwork) {
|
||||||
if (bouncerNetwork && bouncerNetwork.name) {
|
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;
|
return bouncerNetwork.name;
|
||||||
}
|
}
|
||||||
if (isBouncer) {
|
|
||||||
return "bouncer";
|
|
||||||
}
|
|
||||||
|
|
||||||
let netName = server.isupport.get("NETWORK");
|
|
||||||
if (netName) {
|
if (netName) {
|
||||||
|
// Server has specified a name
|
||||||
return netName;
|
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) {
|
function updateState(state, updater) {
|
||||||
@@ -112,7 +118,7 @@ function compareBuffers(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateMembership(membership, letter, add, client) {
|
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) => {
|
let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
|
||||||
return [membership.prefix, i];
|
return [membership.prefix, i];
|
||||||
@@ -137,7 +143,7 @@ function updateMembership(membership, letter, add, client) {
|
|||||||
function insertMessage(list, msg) {
|
function insertMessage(list, msg) {
|
||||||
if (list.length == 0) {
|
if (list.length == 0) {
|
||||||
return [msg];
|
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);
|
return list.concat(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +231,7 @@ export const State = {
|
|||||||
let cm = irc.CaseMapping.RFC1459;
|
let cm = irc.CaseMapping.RFC1459;
|
||||||
let server = state.servers.get(serverID);
|
let server = state.servers.get(serverID);
|
||||||
if (server) {
|
if (server) {
|
||||||
cm = irc.CaseMapping.byName(server.isupport.get("CASEMAPPING")) || cm;
|
cm = server.cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nameCM = cm(name);
|
let nameCM = cm(name);
|
||||||
@@ -246,9 +252,17 @@ export const State = {
|
|||||||
let servers = new Map(state.servers);
|
let servers = new Map(state.servers);
|
||||||
servers.set(id, {
|
servers.set(id, {
|
||||||
id,
|
id,
|
||||||
|
name: null, // from ISUPPORT NETWORK
|
||||||
status: ServerStatus.DISCONNECTED,
|
status: ServerStatus.DISCONNECTED,
|
||||||
isupport: new Map(),
|
cm: irc.CaseMapping.RFC1459,
|
||||||
users: new irc.CaseMapMap(null, 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 }];
|
return [id, { servers }];
|
||||||
},
|
},
|
||||||
@@ -277,6 +291,7 @@ export const State = {
|
|||||||
type,
|
type,
|
||||||
server: serverID,
|
server: serverID,
|
||||||
serverInfo: null, // if server
|
serverInfo: null, // if server
|
||||||
|
joined: false, // if channel
|
||||||
topic: null, // if channel
|
topic: null, // if channel
|
||||||
members: new irc.CaseMapMap(null, client.cm), // if channel
|
members: new irc.CaseMapMap(null, client.cm), // if channel
|
||||||
messages: [],
|
messages: [],
|
||||||
@@ -311,7 +326,7 @@ export const State = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target, channel, topic, targets, who;
|
let target, channel, topic, targets, who, update, buffers;
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case irc.RPL_MYINFO:
|
case irc.RPL_MYINFO:
|
||||||
// TODO: parse available modes
|
// TODO: parse available modes
|
||||||
@@ -321,7 +336,7 @@ export const State = {
|
|||||||
};
|
};
|
||||||
return updateBuffer(SERVER_BUFFER, { serverInfo });
|
return updateBuffer(SERVER_BUFFER, { serverInfo });
|
||||||
case irc.RPL_ISUPPORT:
|
case irc.RPL_ISUPPORT:
|
||||||
let buffers = new Map(state.buffers);
|
buffers = new Map(state.buffers);
|
||||||
state.buffers.forEach((buf) => {
|
state.buffers.forEach((buf) => {
|
||||||
if (buf.server != serverID) {
|
if (buf.server != serverID) {
|
||||||
return;
|
return;
|
||||||
@@ -333,11 +348,31 @@ export const State = {
|
|||||||
buffers,
|
buffers,
|
||||||
...updateServer((server) => {
|
...updateServer((server) => {
|
||||||
return {
|
return {
|
||||||
isupport: new Map(client.isupport),
|
name: client.isupport.network(),
|
||||||
|
cm: client.cm,
|
||||||
users: new irc.CaseMapMap(server.users, 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:
|
case irc.RPL_NOTOPIC:
|
||||||
channel = msg.params[1];
|
channel = msg.params[1];
|
||||||
return updateBuffer(channel, { topic: null });
|
return updateBuffer(channel, { topic: null });
|
||||||
@@ -348,19 +383,19 @@ export const State = {
|
|||||||
case irc.RPL_TOPICWHOTIME:
|
case irc.RPL_TOPICWHOTIME:
|
||||||
// Ignore
|
// Ignore
|
||||||
break;
|
break;
|
||||||
case irc.RPL_NAMREPLY:
|
case irc.RPL_ENDOFNAMES:
|
||||||
channel = msg.params[2];
|
channel = msg.params[1];
|
||||||
let membersList = msg.params[3].split(" ");
|
|
||||||
|
|
||||||
return updateBuffer(channel, (buf) => {
|
return updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(null, buf.members.caseMap);
|
||||||
membersList.forEach((s) => {
|
msg.list.forEach((namreply) => {
|
||||||
let member = irc.parseTargetPrefix(s);
|
let membersList = namreply.params[3].split(" ");
|
||||||
members.set(member.name, member.prefix);
|
membersList.forEach((s) => {
|
||||||
|
let member = irc.parseTargetPrefix(s);
|
||||||
|
members.set(member.name, member.prefix);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return { members };
|
return { members };
|
||||||
});
|
});
|
||||||
case irc.RPL_ENDOFNAMES:
|
|
||||||
break;
|
break;
|
||||||
case irc.RPL_WHOREPLY:
|
case irc.RPL_WHOREPLY:
|
||||||
case irc.RPL_WHOSPCRPL:
|
case irc.RPL_WHOSPCRPL:
|
||||||
@@ -368,6 +403,7 @@ export const State = {
|
|||||||
|
|
||||||
if (who.flags !== undefined) {
|
if (who.flags !== undefined) {
|
||||||
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
|
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
|
||||||
|
who.operator = who.flags.indexOf("*") >= 0;
|
||||||
delete who.flags;
|
delete who.flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,14 +412,9 @@ export const State = {
|
|||||||
return updateUser(who.nick, who);
|
return updateUser(who.nick, who);
|
||||||
case irc.RPL_ENDOFWHO:
|
case irc.RPL_ENDOFWHO:
|
||||||
target = msg.params[1];
|
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
|
// Not a channel nor a mask, likely a nick
|
||||||
return updateUser(target, (user) => {
|
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 };
|
return { offline: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -396,10 +427,13 @@ export const State = {
|
|||||||
state = { ...state, ...update };
|
state = { ...state, ...update };
|
||||||
}
|
}
|
||||||
|
|
||||||
let update = updateBuffer(channel, (buf) => {
|
update = updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
members.set(msg.prefix.name, "");
|
members.set(msg.prefix.name, "");
|
||||||
return { members };
|
|
||||||
|
let joined = buf.joined || client.isMyNick(msg.prefix.name);
|
||||||
|
|
||||||
|
return { members, joined };
|
||||||
});
|
});
|
||||||
state = { ...state, ...update };
|
state = { ...state, ...update };
|
||||||
|
|
||||||
@@ -427,7 +461,10 @@ export const State = {
|
|||||||
return updateBuffer(channel, (buf) => {
|
return updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
members.delete(msg.prefix.name);
|
members.delete(msg.prefix.name);
|
||||||
return { members };
|
|
||||||
|
let joined = buf.joined && !client.isMyNick(msg.prefix.name);
|
||||||
|
|
||||||
|
return { members, joined };
|
||||||
});
|
});
|
||||||
case "KICK":
|
case "KICK":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
@@ -436,18 +473,54 @@ export const State = {
|
|||||||
return updateBuffer(channel, (buf) => {
|
return updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
members.delete(nick);
|
members.delete(nick);
|
||||||
return { members };
|
|
||||||
|
let joined = buf.joined && !client.isMyNick(nick);
|
||||||
|
|
||||||
|
return { members, joined };
|
||||||
});
|
});
|
||||||
case "QUIT":
|
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) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return { offline: true };
|
return { offline: true };
|
||||||
});
|
});
|
||||||
|
state = { ...state, ...update };
|
||||||
|
|
||||||
|
return state;
|
||||||
case "NICK":
|
case "NICK":
|
||||||
let newNick = msg.params[0];
|
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 users = new irc.CaseMapMap(server.users);
|
||||||
let user = users.get(msg.prefix.name);
|
let user = users.get(msg.prefix.name);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -457,6 +530,9 @@ export const State = {
|
|||||||
users.delete(msg.prefix.name);
|
users.delete(msg.prefix.name);
|
||||||
return { users };
|
return { users };
|
||||||
});
|
});
|
||||||
|
state = { ...state, ...update };
|
||||||
|
|
||||||
|
return state;
|
||||||
case "SETNAME":
|
case "SETNAME":
|
||||||
return updateUser(msg.prefix.name, { realname: msg.params[0] });
|
return updateUser(msg.prefix.name, { realname: msg.params[0] });
|
||||||
case "CHGHOST":
|
case "CHGHOST":
|
||||||
@@ -484,7 +560,7 @@ export const State = {
|
|||||||
return; // TODO: handle user mode changes too
|
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) => {
|
let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
|
||||||
return [membership.mode, membership.prefix];
|
return [membership.mode, membership.prefix];
|
||||||
}));
|
}));
|
||||||
|
|||||||
1
store.js
1
store.js
@@ -23,6 +23,7 @@ class Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const autoconnect = new Item("autoconnect");
|
export const autoconnect = new Item("autoconnect");
|
||||||
|
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
|
||||||
|
|
||||||
const rawReceipts = new Item("receipts");
|
const rawReceipts = new Item("receipts");
|
||||||
|
|
||||||
|
|||||||
10
style.css
10
style.css
@@ -58,6 +58,13 @@ body {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noscript {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 600px;
|
||||||
|
grid-column-start: 2;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: var(--button-background);
|
background: var(--button-background);
|
||||||
transition: background 0.25s linear;
|
transition: background 0.25s linear;
|
||||||
@@ -279,6 +286,9 @@ button.danger:hover {
|
|||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
#member-list li a.away {
|
||||||
|
color: var(--gray);
|
||||||
|
}
|
||||||
|
|
||||||
.membership.owner {
|
.membership.owner {
|
||||||
color: red;
|
color: red;
|
||||||
|
|||||||
Reference in New Issue
Block a user