Compare commits

..

No commits in common. "master" and "v1.0.0-beta.2" have entirely different histories.

39 changed files with 14341 additions and 6187 deletions

View File

@ -3,25 +3,17 @@ packages:
- npm - npm
- rsync - rsync
sources: sources:
- https://codeberg.org/emersion/gamja.git - https://git.sr.ht/~emersion/gamja
secrets: secrets:
- 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key - 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
artifacts:
- gamja/gamja.tar.gz
tasks: tasks:
- setup: | - setup: |
cd gamja cd gamja
npm install --include=dev npm install --include=dev
- build: |
cd gamja
npm run build npm run build
tar -czf gamja.tar.gz -C dist .
- lint: |
cd gamja
npm run -- lint --max-warnings 0
- deploy: | - deploy: |
cd gamja/dist cd gamja/dist
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \ rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
--delete --exclude=config.json \ --delete --exclude=config.json \
. deploy-gamja@sheeta.emersion.fr:/srv/http/gamja . deploy@emersion.fr:/srv/http/gamja

106
README.md
View File

@ -2,7 +2,7 @@
A simple IRC web client. A simple IRC web client.
<img src="https://fs.emersion.fr/protected/img/gamja/main.png" alt="Screenshot" width="800"> ![screenshot](https://l.sr.ht/7Npm.png)
## Usage ## Usage
@ -10,9 +10,54 @@ Requires an IRC WebSocket server.
First install dependencies: First install dependencies:
npm install --omit=dev npm install --production
Then [configure an HTTP server] to serve the gamja files. Then configure an HTTP server to serve the gamja files. Below are some
server-specific instructions.
### [soju]
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
Configure your reverse proxy to serve gamja files and proxy `/socket` to soju.
### [webircgateway]
Setup webircgateway to serve gamja files:
```ini
[fileserving]
enabled = true
webroot = /path/to/gamja
```
Then connect to webircgateway and append `?server=/webirc/websocket/` to the
URL.
### nginx
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
timeout to a value higher than the IRC server PING interval. Example:
```
location / {
root /path/to/gamja;
}
location /socket {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
If you are unable to configure the proxy timeout accordingly, or if your IRC
server doesn't send PINGs, you can set the `server.ping` option in
`config.json` (see below).
### Development server ### Development server
@ -31,13 +76,52 @@ Optionally, [Parcel] can be used to build a minified version of gamja.
npm install --include=dev npm install --include=dev
npm run build npm run build
## Configuration ## Query parameters
gamja can be configured via a [configuration file] and via [URL parameters]. gamja settings can be overridden using URL query parameters:
- `server`: path or URL to the WebSocket server
- `nick`: nickname
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open
- `debug`: if set to 1, debug mode is enabled
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
## Configuration file
gamja default settings can be set using a `config.json` file at the root:
```js
{
// IRC server settings.
"server": {
// WebSocket URL or path to connect to (string).
"url": "wss://irc.example.org",
// Channel(s) to auto-join (string or array of strings).
"autojoin": "#gamja",
// Controls how the password UI is presented to the user. Set to
// "mandatory" to require a password, "optional" to accept one but not
// require it, "disabled" to never ask for a password, or "external" to
// use SASL EXTERNAL. Defaults to "optional".
"auth": "optional",
// Default nickname (string).
"nick": "asdf",
// Don't display the login UI, immediately connect to the server
// (boolean).
"autoconnect": true,
// Interval in seconds to send PING commands (number). Set to 0 to
// disable. Enabling PINGs can have an impact on client power usage and
// should only be enabled if necessary.
"ping": 60
}
}
```
## Contributing ## Contributing
Send patches on [Codeberg], 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
@ -46,11 +130,11 @@ AGPLv3, see LICENSE.
Copyright (C) 2020 The gamja Contributors Copyright (C) 2020 The gamja Contributors
[gamja]: https://codeberg.org/emersion/gamja [gamja]: https://sr.ht/~emersion/gamja/
[Codeberg]: https://codeberg.org/emersion/gamja [soju]: https://soju.im
[webircgateway]: https://github.com/kiwiirc/webircgateway
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
[issue tracker]: https://todo.sr.ht/~emersion/gamja [issue tracker]: https://todo.sr.ht/~emersion/gamja
[Parcel]: https://parceljs.org [Parcel]: https://parceljs.org
[configure an HTTP server]: doc/setup.md [IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
[configuration file]: doc/config-file.md
[URL parameters]: doc/url-params.md
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju [#soju on Libera Chat]: ircs://irc.libera.chat/#soju

View File

@ -25,14 +25,14 @@ function getActiveChannel(app) {
return activeBuffer.name; return activeBuffer.name;
} }
async function setUserHostMode(app, args, mode) { function setUserHostMode(app, args, mode) {
let nick = args[0]; let nick = args[0];
if (!nick) { if (!nick) {
throw new Error("Missing nick"); throw new Error("Missing nick");
} }
let activeChannel = getActiveChannel(app); let activeChannel = getActiveChannel(app);
let client = getActiveClient(app); let client = getActiveClient(app);
let whois = await client.whois(nick); client.whois(nick).then((whois) => {
const info = whois[irc.RPL_WHOISUSER].params; const info = whois[irc.RPL_WHOISUSER].params;
const user = info[2]; const user = info[2];
const host = info[3]; const host = info[3];
@ -40,6 +40,7 @@ async function setUserHostMode(app, args, mode) {
command: "MODE", command: "MODE",
params: [activeChannel, mode, `*!${user}@${host}`], params: [activeChannel, mode, `*!${user}@${host}`],
}); });
});
} }
function markServerBufferUnread(app) { function markServerBufferUnread(app) {
@ -53,25 +54,19 @@ function markServerBufferUnread(app) {
} }
const join = { const join = {
name: "join", usage: "<name>",
usage: "<name> [password]",
description: "Join a channel", description: "Join a channel",
execute: (app, args) => { execute: (app, args) => {
let channel = args[0]; let channel = args[0];
if (!channel) { if (!channel) {
throw new Error("Missing channel name"); throw new Error("Missing channel name");
} }
if (args.length > 1) {
app.open(channel, null, args[1]);
} else {
app.open(channel); app.open(channel);
}
}, },
}; };
const kick = { const kick = {
name: "kick", usage: "<nick>",
usage: "<nick> [comment]",
description: "Remove a user from the channel", description: "Remove a user from the channel",
execute: (app, args) => { execute: (app, args) => {
let nick = args[0]; let nick = args[0];
@ -84,23 +79,6 @@ const kick = {
}, },
}; };
const ban = {
name: "ban",
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => {
if (args.length === 0) {
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({
command: "MODE",
params: [activeChannel, "+b"],
});
} else {
return setUserHostMode(app, args, "+b");
}
},
};
function givemode(app, args, mode) { function givemode(app, args, mode) {
// TODO: Handle several users at once // TODO: Handle several users at once
let nick = args[0]; let nick = args[0];
@ -114,22 +92,34 @@ function givemode(app, args, mode) {
}); });
} }
const commands = [ export default {
{ "away": {
name: "away",
usage: "[message]", usage: "[message]",
description: "Set away message", description: "Set away message",
execute: (app, args) => { execute: (app, args) => {
const params = []; const params = []
if (args.length) { if (args.length) {
params.push(args.join(" ")); params.push(args.join(" "));
} }
getActiveClient(app).send({command: "AWAY", params}); getActiveClient(app).send({command: "AWAY", params});
}, },
}, },
ban, "ban": {
{ usage: "[nick]",
name: "buffer", description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => {
if (args.length == 0) {
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({
command: "MODE",
params: [activeChannel, "+b"],
});
} else {
return setUserHostMode(app, args, "+b");
}
},
},
"buffer": {
usage: "<name>", usage: "<name>",
description: "Switch to a buffer", description: "Switch to a buffer",
execute: (app, args) => { execute: (app, args) => {
@ -143,45 +133,39 @@ const commands = [
throw new Error("Unknown buffer"); throw new Error("Unknown buffer");
}, },
}, },
{ "close": {
name: "close",
description: "Close the current buffer", description: "Close the current buffer",
execute: (app, args) => { execute: (app, args) => {
let activeBuffer = app.state.buffers.get(app.state.activeBuffer); let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
if (!activeBuffer || activeBuffer.type === BufferType.SERVER) { if (!activeBuffer || activeBuffer.type == BufferType.SERVER) {
throw new Error("Not in a user or channel buffer"); throw new Error("Not in a user or channel buffer");
} }
app.close(activeBuffer.id); app.close(activeBuffer.id);
}, },
}, },
{ "deop": {
name: "deop",
usage: "<nick>", usage: "<nick>",
description: "Remove operator status for a user on this channel", description: "Remove operator status for a user on this channel",
execute: (app, args) => givemode(app, args, "-o"), execute: (app, args) => givemode(app, args, "-o"),
}, },
{ "devoice": {
name: "devoice",
usage: "<nick>", usage: "<nick>",
description: "Remove voiced status for a user on this channel", description: "Remove voiced status for a user on this channel",
execute: (app, args) => givemode(app, args, "-v"), execute: (app, args) => givemode(app, args, "-v"),
}, },
{ "disconnect": {
name: "disconnect",
description: "Disconnect from the server", description: "Disconnect from the server",
execute: (app, args) => { execute: (app, args) => {
app.disconnect(); app.disconnect();
}, },
}, },
{ "help": {
name: "help",
description: "Show help menu", description: "Show help menu",
execute: (app, args) => { execute: (app, args) => {
app.openHelp(); app.openHelp();
}, },
}, },
{ "invite": {
name: "invite",
usage: "<nick>", usage: "<nick>",
description: "Invite a user to the channel", description: "Invite a user to the channel",
execute: (app, args) => { execute: (app, args) => {
@ -190,17 +174,15 @@ const commands = [
throw new Error("Missing nick"); throw new Error("Missing nick");
} }
let activeChannel = getActiveChannel(app); let activeChannel = getActiveChannel(app);
getActiveClient(app).send({ getActiveClient(app).send({ command: "INVITE", params: [
command: "INVITE", nick, activeChannel,
params: [nick, activeChannel], ]});
});
}, },
}, },
{ ...join, name: "j" }, "j": join,
join, "join": join,
kick, "kick": kick,
{ "kickban": {
name: "kickban",
usage: "<target>", usage: "<target>",
description: "Ban a user and removes them from the channel", description: "Ban a user and removes them from the channel",
execute: (app, args) => { execute: (app, args) => {
@ -208,8 +190,7 @@ const commands = [
ban.execute(app, args); ban.execute(app, args);
}, },
}, },
{ "lusers": {
name: "lusers",
usage: "[<mask> [<target>]]", usage: "[<mask> [<target>]]",
description: "Request user statistics about the network", description: "Request user statistics about the network",
execute: (app, args) => { execute: (app, args) => {
@ -217,8 +198,7 @@ const commands = [
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
{ "me": {
name: "me",
usage: "<action>", usage: "<action>",
description: "Send an action message to the current buffer", description: "Send an action message to the current buffer",
execute: (app, args) => { execute: (app, args) => {
@ -228,8 +208,7 @@ const commands = [
app.privmsg(target, text); app.privmsg(target, text);
}, },
}, },
{ "mode": {
name: "mode",
usage: "[target] [modes] [mode args...]", usage: "[target] [modes] [mode args...]",
description: "Query or change a channel or user mode", description: "Query or change a channel or user mode",
execute: (app, args) => { execute: (app, args) => {
@ -241,8 +220,7 @@ const commands = [
getActiveClient(app).send({ command: "MODE", params: args }); getActiveClient(app).send({ command: "MODE", params: args });
}, },
}, },
{ "motd": {
name: "motd",
usage: "[server]", usage: "[server]",
description: "Get the Message Of The Day", description: "Get the Message Of The Day",
execute: (app, args) => { execute: (app, args) => {
@ -250,8 +228,7 @@ const commands = [
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
{ "msg": {
name: "msg",
usage: "<target> <message>", usage: "<target> <message>",
description: "Send a message to a nickname or a channel", description: "Send a message to a nickname or a channel",
execute: (app, args) => { execute: (app, args) => {
@ -260,8 +237,7 @@ const commands = [
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] }); getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
}, },
}, },
{ "nick": {
name: "nick",
usage: "<nick>", usage: "<nick>",
description: "Change current nickname", description: "Change current nickname",
execute: (app, args) => { execute: (app, args) => {
@ -269,8 +245,7 @@ const commands = [
getActiveClient(app).send({ command: "NICK", params: [newNick] }); getActiveClient(app).send({ command: "NICK", params: [newNick] });
}, },
}, },
{ "notice": {
name: "notice",
usage: "<target> <message>", usage: "<target> <message>",
description: "Send a notice to a nickname or a channel", description: "Send a notice to a nickname or a channel",
execute: (app, args) => { execute: (app, args) => {
@ -279,14 +254,12 @@ const commands = [
getActiveClient(app).send({ command: "NOTICE", params: [target, text] }); getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
}, },
}, },
{ "op": {
name: "op",
usage: "<nick>", usage: "<nick>",
description: "Give a user operator status on this channel", description: "Give a user operator status on this channel",
execute: (app, args) => givemode(app, args, "+o"), execute: (app, args) => givemode(app, args, "+o"),
}, },
{ "part": {
name: "part",
usage: "[reason]", usage: "[reason]",
description: "Leave a channel", description: "Leave a channel",
execute: (app, args) => { execute: (app, args) => {
@ -299,8 +272,7 @@ const commands = [
getActiveClient(app).send({ command: "PART", params }); getActiveClient(app).send({ command: "PART", params });
}, },
}, },
{ "query": {
name: "query",
usage: "<nick> [message]", usage: "<nick> [message]",
description: "Open a buffer to send messages to a nickname", description: "Open a buffer to send messages to a nickname",
execute: (app, args) => { execute: (app, args) => {
@ -316,12 +288,11 @@ const commands = [
} }
}, },
}, },
{ "quiet": {
name: "quiet",
usage: "[nick]", usage: "[nick]",
description: "Quiet a user in the channel, or display the current quiet list", description: "Quiet a user in the channel, or display the current quiet list",
execute: (app, args) => { execute: (app, args) => {
if (args.length === 0) { if (args.length == 0) {
getActiveClient(app).send({ getActiveClient(app).send({
command: "MODE", command: "MODE",
params: [getActiveChannel(app), "+q"], params: [getActiveChannel(app), "+q"],
@ -331,15 +302,13 @@ const commands = [
} }
}, },
}, },
{ "quit": {
name: "quit",
description: "Quit", description: "Quit",
execute: (app, args) => { execute: (app, args) => {
app.close({ name: SERVER_BUFFER }); app.close({ name: SERVER_BUFFER });
}, },
}, },
{ "quote": {
name: "quote",
usage: "<command>", usage: "<command>",
description: "Send a raw IRC command to the server", description: "Send a raw IRC command to the server",
execute: (app, args) => { execute: (app, args) => {
@ -352,15 +321,13 @@ const commands = [
getActiveClient(app).send(msg); getActiveClient(app).send(msg);
}, },
}, },
{ "reconnect": {
name: "reconnect",
description: "Reconnect to the server", description: "Reconnect to the server",
execute: (app, args) => { execute: (app, args) => {
app.reconnect(); app.reconnect();
}, },
}, },
{ "setname": {
name: "setname",
usage: "<realname>", usage: "<realname>",
description: "Change current realname", description: "Change current realname",
execute: (app, args) => { execute: (app, args) => {
@ -373,8 +340,7 @@ const commands = [
// TODO: save to local storage // TODO: save to local storage
}, },
}, },
{ "stats": {
name: "stats",
usage: "<query> [server]", usage: "<query> [server]",
description: "Request server statistics", description: "Request server statistics",
execute: (app, args) => { execute: (app, args) => {
@ -390,8 +356,7 @@ const commands = [
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
{ "topic": {
name: "topic",
usage: "<topic>", usage: "<topic>",
description: "Change the topic of the current channel", description: "Change the topic of the current channel",
execute: (app, args) => { execute: (app, args) => {
@ -403,39 +368,39 @@ const commands = [
getActiveClient(app).send({ command: "TOPIC", params }); getActiveClient(app).send({ command: "TOPIC", params });
}, },
}, },
{ "unban": {
name: "unban",
usage: "<nick>", usage: "<nick>",
description: "Remove a user from the ban list", description: "Remove a user from the ban list",
execute: (app, args) => { execute: (app, args) => {
return setUserHostMode(app, args, "-b"); return setUserHostMode(app, args, "-b");
}, },
}, },
{ "unquiet": {
name: "unquiet",
usage: "<nick>", usage: "<nick>",
description: "Remove a user from the quiet list", description: "Remove a user from the quiet list",
execute: (app, args) => { execute: (app, args) => {
return setUserHostMode(app, args, "-q"); return setUserHostMode(app, args, "-q");
}, },
}, },
{ "unvoice": {
name: "voice", usage: "<nick>",
description: "Remove a user from the voiced list",
execute: (app, args) => givemode(app, args, "-v"),
},
"voice": {
usage: "<nick>", usage: "<nick>",
description: "Give a user voiced status on this channel", description: "Give a user voiced status on this channel",
execute: (app, args) => givemode(app, args, "+v"), execute: (app, args) => givemode(app, args, "+v"),
}, },
{ "who": {
name: "who", usage: "[<mask> [o]]",
usage: "<mask>",
description: "Retrieve a list of users", description: "Retrieve a list of users",
execute: (app, args) => { execute: (app, args) => {
getActiveClient(app).send({ command: "WHO", params: args }); getActiveClient(app).send({ command: "WHO", params: args });
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
{ "whois": {
name: "whois",
usage: "<nick>", usage: "<nick>",
description: "Retrieve information about a user", description: "Retrieve information about a user",
execute: (app, args) => { execute: (app, args) => {
@ -447,8 +412,7 @@ const commands = [
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
{ "whowas": {
name: "whowas",
usage: "<nick> [count]", usage: "<nick> [count]",
description: "Retrieve information about an offline user", description: "Retrieve information about an offline user",
execute: (app, args) => { execute: (app, args) => {
@ -459,8 +423,7 @@ const commands = [
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
{ "list": {
name: "list",
usage: "[filter]", usage: "[filter]",
description: "Retrieve a list of channels from a network", description: "Retrieve a list of channels from a network",
execute: (app, args) => { execute: (app, args) => {
@ -468,6 +431,4 @@ const commands = [
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
]; };
export default new Map(commands.map((cmd) => [cmd.name, cmd]));

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ export default class NetworkForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleInput = this.handleInput.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.username) { if (props.username) {
@ -17,9 +17,9 @@ export default class NetworkForm extends Component {
} }
} }
handleInput(event) { handleChange(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@ -31,7 +31,7 @@ export default class NetworkForm extends Component {
render() { render() {
return html` return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}> <form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label> <label>
Username:<br/> Username:<br/>
<input type="username" name="username" value=${this.state.username} required/> <input type="username" name="username" value=${this.state.username} required/>

View File

@ -1,4 +1,4 @@
import { html } from "../lib/index.js"; import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js"; import linkify from "../lib/linkify.js";
import { strip as stripANSI } from "../lib/ansi.js"; import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, ServerStatus, getServerName } from "../state.js"; import { BufferType, ServerStatus, getServerName } from "../state.js";
@ -44,9 +44,6 @@ export default function BufferHeader(props) {
switch (props.bouncerNetwork.state) { switch (props.bouncerNetwork.state) {
case "disconnected": case "disconnected":
description = "Bouncer disconnected from network"; description = "Bouncer disconnected from network";
if (props.bouncerNetwork.error) {
description += ": " + props.bouncerNetwork.error;
}
break; break;
case "connecting": case "connecting":
description = "Bouncer connecting to network..."; description = "Bouncer connecting to network...";
@ -77,12 +74,6 @@ export default function BufferHeader(props) {
onClick=${props.onReconnect} onClick=${props.onReconnect}
>Reconnect</button> >Reconnect</button>
`; `;
let settingsButton = html`
<button
key="settings"
onClick="${props.onOpenSettings}"
>Settings</button>
`;
if (props.server.isBouncer) { if (props.server.isBouncer) {
if (props.server.bouncerNetID) { if (props.server.bouncerNetID) {
@ -108,7 +99,13 @@ export default function BufferHeader(props) {
} else if (props.server.status === ServerStatus.DISCONNECTED) { } else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton); actions.push(reconnectButton);
} }
actions.push(settingsButton); actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
} }
} else { } else {
if (fullyConnected) { if (fullyConnected) {
@ -116,7 +113,13 @@ export default function BufferHeader(props) {
} else if (props.server.status === ServerStatus.DISCONNECTED) { } else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton); actions.push(reconnectButton);
} }
actions.push(settingsButton); actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
} }
break; break;
case BufferType.CHANNEL: case BufferType.CHANNEL:
@ -186,10 +189,6 @@ export default function BufferHeader(props) {
let desc = "This user is a server operator, they have administrator privileges."; let desc = "This user is a server operator, they have administrator privileges.";
details.push(html`<abbr title=${desc}>server operator</abbr>`); details.push(html`<abbr title=${desc}>server operator</abbr>`);
} }
if (props.user.bot) {
let desc = "This user is an automated bot.";
details.push(html`<abbr title=${desc}>bot</abbr>`);
}
details = details.map((item, i) => { details = details.map((item, i) => {
if (i === 0) { if (i === 0) {
return item; return item;
@ -214,7 +213,7 @@ export default function BufferHeader(props) {
} }
let name = props.buffer.name; let name = props.buffer.name;
if (props.buffer.type === BufferType.SERVER) { if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork); name = getServerName(props.server, props.bouncerNetwork);
} }

View File

@ -1,7 +1,6 @@
import * as irc from "../lib/irc.js"; import * as irc from "../lib/irc.js";
import { strip as stripANSI } from "../lib/ansi.js"; import { html, Component } from "../lib/index.js";
import { html } from "../lib/index.js"; import { BufferType, Unread, getBufferURL, getServerName } from "../state.js";
import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
function BufferItem(props) { function BufferItem(props) {
function handleClick(event) { function handleClick(event) {
@ -16,41 +15,22 @@ function BufferItem(props) {
} }
let name = props.buffer.name; let name = props.buffer.name;
if (props.buffer.type === BufferType.SERVER) { if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork); name = getServerName(props.server, props.bouncerNetwork);
} }
let title;
let classes = ["type-" + props.buffer.type]; let classes = ["type-" + props.buffer.type];
if (props.active) { if (props.active) {
classes.push("active"); classes.push("active");
} }
if (props.buffer.unread !== Unread.NONE) { if (props.buffer.unread != Unread.NONE) {
classes.push("unread-" + props.buffer.unread); classes.push("unread-" + props.buffer.unread);
} }
switch (props.buffer.type) {
case BufferType.SERVER:
let isError = props.server.status === ServerStatus.DISCONNECTED;
if (props.bouncerNetwork && props.bouncerNetwork.error) {
isError = true;
}
if (isError) {
classes.push("error");
}
break;
case BufferType.NICK:
let user = props.server.users.get(name);
if (user && irc.isMeaningfulRealname(user.realname, name)) {
title = stripANSI(user.realname);
}
break;
}
return html` return html`
<li class="${classes.join(" ")}"> <li class="${classes.join(" ")}">
<a <a
href=${getBufferURL(props.buffer)} href=${getBufferURL(props.buffer)}
title=${title}
onClick=${handleClick} onClick=${handleClick}
onMouseDown=${handleMouseDown} onMouseDown=${handleMouseDown}
>${name}</a> >${name}</a>
@ -58,13 +38,15 @@ function BufferItem(props) {
`; `;
} }
export default function BufferList(props) { export default function BufferList(props) {
let items = Array.from(props.buffers.values()).map((buf) => { let items = Array.from(props.buffers.values()).map((buf) => {
let server = props.servers.get(buf.server); let server = props.servers.get(buf.server);
let bouncerNetwork = null; let bouncerNetwork = null;
if (server.bouncerNetID) { let bouncerNetID = server.bouncerNetID;
bouncerNetwork = props.bouncerNetworks.get(server.bouncerNetID); if (bouncerNetID) {
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
} }
return html` return html`
@ -75,7 +57,7 @@ export default function BufferList(props) {
bouncerNetwork=${bouncerNetwork} bouncerNetwork=${bouncerNetwork}
onClick=${() => props.onBufferClick(buf)} onClick=${() => props.onBufferClick(buf)}
onClose=${() => props.onBufferClose(buf)} onClose=${() => props.onBufferClose(buf)}
active=${props.activeBuffer === buf.id} active=${props.activeBuffer == buf.id}
/> />
`; `;
}); });

View File

@ -2,7 +2,7 @@ import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js"; import linkify from "../lib/linkify.js";
import * as irc from "../lib/irc.js"; import * as irc from "../lib/irc.js";
import { strip as stripANSI } from "../lib/ansi.js"; import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, ServerStatus, BufferEventsDisplayMode, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js"; import { BufferType, ServerStatus, getNickURL, getChannelURL, getMessageURL } from "../state.js";
import * as store from "../store.js"; import * as store from "../store.js";
import Membership from "./membership.js"; import Membership from "./membership.js";
@ -21,38 +21,21 @@ function Nick(props) {
props.onClick(); props.onClick();
} }
let title;
if (props.user && irc.isMeaningfulRealname(props.user.realname, props.nick)) {
title = stripANSI(props.user.realname);
}
let colorIndex = djb2(props.nick) % 16 + 1; let colorIndex = djb2(props.nick) % 16 + 1;
return html` return html`
<a <a href=${getNickURL(props.nick)} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
href=${irc.formatURL({ entity: props.nick })}
title=${title}
class="nick nick-${colorIndex}"
onClick=${handleClick}
>${props.nick}</a>
`; `;
} }
function _Timestamp({ date, url, showSeconds }) { function Timestamp({ date, url }) {
if (!date) { if (!date) {
let timestamp = "--:--"; return html`<spam class="timestamp">--:--:--</span>`;
if (showSeconds) {
timestamp += ":--";
}
return html`<span class="timestamp">${timestamp}</span>`;
} }
let hh = date.getHours().toString().padStart(2, "0"); let hh = date.getHours().toString().padStart(2, "0");
let mm = date.getMinutes().toString().padStart(2, "0"); let mm = date.getMinutes().toString().padStart(2, "0");
let timestamp = `${hh}:${mm}`;
if (showSeconds) {
let ss = date.getSeconds().toString().padStart(2, "0"); let ss = date.getSeconds().toString().padStart(2, "0");
timestamp += ":" + ss; let timestamp = `${hh}:${mm}:${ss}`;
}
return html` return html`
<a <a
href=${url} href=${url}
@ -65,16 +48,6 @@ function _Timestamp({ date, url, showSeconds }) {
`; `;
} }
function Timestamp(props) {
return html`
<${SettingsContext.Consumer}>
${(settings) => html`
<${_Timestamp} ...${props} showSeconds=${settings.secondsInTimestamps}/>
`}
</>
`;
}
/** /**
* Check whether a message can be folded. * Check whether a message can be folded.
* *
@ -94,7 +67,7 @@ function canFoldMessage(msg) {
class LogLine extends Component { class LogLine extends Component {
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return this.props.message !== nextProps.message || this.props.redacted !== nextProps.redacted; return this.props.message !== nextProps.message;
} }
render() { render() {
@ -108,16 +81,16 @@ class LogLine extends Component {
function createNick(nick) { function createNick(nick) {
return html` return html`
<${Nick} <${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`; `;
} }
function createChannel(channel) { function createChannel(channel) {
function onClick(event) {
event.preventDefault();
onChannelClick(channel);
}
return html` return html`
<a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}> <a href=${getChannelURL(channel)} onClick=${onClick}>
${channel} ${channel}
</a> </a>
`; `;
@ -134,7 +107,7 @@ class LogLine extends Component {
let ctcp = irc.parseCTCP(msg); let ctcp = irc.parseCTCP(msg);
if (ctcp) { if (ctcp) {
if (ctcp.command === "ACTION") { if (ctcp.command == "ACTION") {
lineClass = "me-tell"; lineClass = "me-tell";
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`; content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
} else { } else {
@ -143,20 +116,15 @@ class LogLine extends Component {
`; `;
} }
} else { } else {
lineClass = "talk";
let prefix = "<", suffix = ">"; let prefix = "<", suffix = ">";
if (msg.command === "NOTICE") { if (msg.command == "NOTICE") {
lineClass += " notice";
prefix = suffix = "-"; prefix = suffix = "-";
} }
if (this.props.redacted) { content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`;
content = html`<i>This message has been deleted.</i>`;
} else {
content = html`${linkify(stripANSI(text), onChannelClick)}`;
lineClass += " talk";
}
content = html`<span class="nick-caret">${prefix}</span>${createNick(msg.prefix.name)}<span class="nick-caret">${suffix}</span> ${content}`;
} }
let status = null;
let allowedPrefixes = server.statusMsg; let allowedPrefixes = server.statusMsg;
if (target !== buf.name && allowedPrefixes) { if (target !== buf.name && allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes); let parts = irc.parseTargetPrefix(target, allowedPrefixes);
@ -165,10 +133,6 @@ class LogLine extends Component {
} }
} }
if (msg.tags["+draft/channel-context"]) {
content = html`<em>(only visible to you)</em> ${content}`;
}
if (msg.isHighlight) { if (msg.isHighlight) {
lineClass += " highlight"; lineClass += " highlight";
} }
@ -201,94 +165,19 @@ class LogLine extends Component {
break; break;
case "MODE": case "MODE":
target = msg.params[0]; target = msg.params[0];
let modeStr = msg.params[1];
let user = html`${createNick(msg.prefix.name)}`;
// TODO: use irc.forEachChannelModeUpdate()
if (buf.type === BufferType.CHANNEL && modeStr.length === 2 && server.cm(buf.name) === server.cm(target)) {
let plusMinus = modeStr[0];
let mode = modeStr[1];
let arg = msg.params[2];
let verb;
switch (mode) {
case "b":
verb = plusMinus === "+" ? "added" : "removed";
content = html`${user} has ${verb} a ban on ${arg}`;
break;
case "e":
verb = plusMinus === "+" ? "added" : "removed";
content = html`${user} has ${verb} a ban exemption on ${arg}`;
break;
case "l":
if (plusMinus === "+") {
content = html`${user} has set the channel user limit to ${arg}`;
} else {
content = html`${user} has unset the channel user limit`;
}
break;
case "i":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as invite-only`;
break;
case "m":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as moderated`;
break;
case "s":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as secret`;
break;
case "t":
verb = plusMinus === "+" ? "locked": "unlocked";
content = html`${user} has ${verb} the channel topic`;
break;
case "n":
verb = plusMinus === "+" ? "allowed": "denied";
content = html`${user} has ${verb} external messages to this channel`;
break;
}
if (content) {
break;
}
// Channel membership modes
let membership;
for (let prefix in irc.STD_MEMBERSHIP_MODES) {
if (irc.STD_MEMBERSHIP_MODES[prefix] === mode) {
membership = irc.STD_MEMBERSHIP_NAMES[prefix];
break;
}
}
if (membership && arg) {
let verb = plusMinus === "+" ? "granted" : "revoked";
let preposition = plusMinus === "+" ? "to" : "from";
content = html` content = html`
${user} has ${verb} ${membership} privileges ${preposition} ${createNick(arg)} * ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
`; `;
break; // TODO: case-mapping
} if (buf.name !== target) {
}
content = html`
${user} sets mode ${msg.params.slice(1).join(" ")}
`;
if (server.cm(buf.name) !== server.cm(target)) {
content = html`${content} on ${target}`; content = html`${content} on ${target}`;
} }
break; break;
case "TOPIC": case "TOPIC":
let topic = msg.params[1]; let topic = msg.params[1];
if (topic) {
content = html` content = html`
${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)} ${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
`; `;
} else {
content = html`
${createNick(msg.prefix.name)} cleared the topic
`;
}
break; break;
case "INVITE": case "INVITE":
invitee = msg.params[0]; invitee = msg.params[0];
@ -361,15 +250,8 @@ class LogLine extends Component {
let date = new Date(parseInt(msg.params[2], 10) * 1000); let date = new Date(parseInt(msg.params[2], 10) * 1000);
content = html`Channel was created on ${date.toLocaleString()}`; content = html`Channel was created on ${date.toLocaleString()}`;
break; break;
// MONITOR messages are only displayed in user buffers
case irc.RPL_MONONLINE:
content = html`${createNick(buf.name)} is online`;
break;
case irc.RPL_MONOFFLINE:
content = html`${createNick(buf.name)} is offline`;
break;
default: default:
if (irc.isError(msg.command) && msg.command !== irc.ERR_NOMOTD) { if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
lineClass = "error"; lineClass = "error";
} }
content = html`${msg.command} ${linkify(msg.params.join(" "))}`; content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
@ -419,16 +301,11 @@ class FoldGroup extends Component {
render() { render() {
let msgs = this.props.messages; let msgs = this.props.messages;
let buf = this.props.buffer; let buf = this.props.buffer;
let server = this.props.server;
let onNickClick = this.props.onNickClick; let onNickClick = this.props.onNickClick;
function createNick(nick) { function createNick(nick) {
return html` return html`
<${Nick} <${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`; `;
} }
@ -654,7 +531,10 @@ class DateSeparator extends Component {
render() { render() {
let date = this.props.date; let date = this.props.date;
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" }); let YYYY = date.getFullYear().toString().padStart(4, "0");
let MM = (date.getMonth() + 1).toString().padStart(2, "0");
let DD = date.getDate().toString().padStart(2, "0");
let text = `${YYYY}-${MM}-${DD}`;
return html` return html`
<div class="separator date-separator"> <div class="separator date-separator">
${text} ${text}
@ -673,8 +553,7 @@ function sameDate(d1, d2) {
export default class Buffer extends Component { export default class Buffer extends Component {
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return this.props.buffer !== nextProps.buffer || return this.props.buffer !== nextProps.buffer;
this.props.settings !== nextProps.settings;
} }
render() { render() {
@ -684,17 +563,17 @@ export default class Buffer extends Component {
} }
let server = this.props.server; let server = this.props.server;
let settings = this.props.settings; let bouncerNetwork = this.props.bouncerNetwork;
let serverName = server.name; let serverName = server.name;
let children = []; let children = [];
if (buf.type === BufferType.SERVER) { if (buf.type == BufferType.SERVER) {
children.push(html`<${NotificationNagger}/>`); children.push(html`<${NotificationNagger}/>`);
} }
if (buf.type === BufferType.SERVER && server.isBouncer && !server.bouncerNetID) { if (buf.type == BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`); children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`);
} }
if (buf.type === BufferType.SERVER && server.status === ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) { if (buf.type == BufferType.SERVER && server.status == ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) {
children.push(html` children.push(html`
<${AccountNagger} <${AccountNagger}
server=${server} server=${server}
@ -715,7 +594,6 @@ export default class Buffer extends Component {
message=${msg} message=${msg}
buffer=${buf} buffer=${buf}
server=${server} server=${server}
redacted=${buf.redacted.has(msg.tags.msgid)}
onChannelClick=${onChannelClick} onChannelClick=${onChannelClick}
onNickClick=${onNickClick} onNickClick=${onNickClick}
onVerifyClick=${onVerifyClick} onVerifyClick=${onVerifyClick}
@ -723,38 +601,7 @@ export default class Buffer extends Component {
`; `;
} }
function createFoldGroup(msgs) { function createFoldGroup(msgs) {
// Merge NICK change chains // Filter out PART → JOIN pairs
let nickChanges = new Map();
let mergedMsgs = [];
for (let msg of msgs) {
let keep = true;
switch (msg.command) {
case "PART":
case "QUIT":
nickChanges.delete(msg.prefix.name);
break;
case "NICK":
let prev = nickChanges.get(msg.prefix.name);
if (!prev) {
// Future NICK messages may mutate this one
msg = { ...msg };
nickChanges.set(msg.params[0], msg);
break;
}
prev.params = msg.params;
nickChanges.delete(msg.prefix.name);
nickChanges.set(msg.params[0], prev);
keep = false;
break;
}
if (keep) {
mergedMsgs.push(msg);
}
}
msgs = mergedMsgs;
// Filter out PART → JOIN pairs, as well as no-op NICKs from previous step
let partIndexes = new Map(); let partIndexes = new Map();
let keep = []; let keep = [];
msgs.forEach((msg, i) => { msgs.forEach((msg, i) => {
@ -765,8 +612,6 @@ export default class Buffer extends Component {
keep[partIndexes.get(msg.prefix.name)] = false; keep[partIndexes.get(msg.prefix.name)] = false;
partIndexes.delete(msg.prefix.name); partIndexes.delete(msg.prefix.name);
keep.push(false); keep.push(false);
} else if (msg.command === "NICK" && msg.prefix.name === msg.params[0]) {
keep.push(false);
} else { } else {
keep.push(true); keep.push(true);
} }
@ -792,23 +637,10 @@ export default class Buffer extends Component {
let hasUnreadSeparator = false; let hasUnreadSeparator = false;
let prevDate = new Date(); let prevDate = new Date();
let foldMessages = []; let foldMessages = [];
let lastMonitor = null;
buf.messages.forEach((msg) => { buf.messages.forEach((msg) => {
let sep = []; let sep = [];
if (settings.bufferEvents === BufferEventsDisplayMode.HIDE && canFoldMessage(msg)) { if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.prevReadReceipt && msg.tags.time > buf.prevReadReceipt.time) {
return;
}
if (msg.command === irc.RPL_MONONLINE || msg.command === irc.RPL_MONOFFLINE) {
let skip = !lastMonitor || msg.command === lastMonitor;
lastMonitor = msg.command;
if (skip) {
return;
}
}
if (!hasUnreadSeparator && buf.type !== BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
sep.push(html`<${UnreadSeparator} key="unread"/>`); sep.push(html`<${UnreadSeparator} key="unread"/>`);
hasUnreadSeparator = true; hasUnreadSeparator = true;
} }
@ -821,12 +653,12 @@ export default class Buffer extends Component {
if (sep.length > 0) { if (sep.length > 0) {
children.push(createFoldGroup(foldMessages)); children.push(createFoldGroup(foldMessages));
children.push(...sep); children.push(sep);
foldMessages = []; foldMessages = [];
} }
// TODO: consider checking the time difference too // TODO: consider checking the time difference too
if (settings.bufferEvents === BufferEventsDisplayMode.FOLD && canFoldMessage(msg)) { if (canFoldMessage(msg)) {
foldMessages.push(msg); foldMessages.push(msg);
return; return;
} }

View File

@ -1,16 +1,5 @@
import { html, Component, createRef } from "../lib/index.js"; import { html, Component, createRef } from "../lib/index.js";
function encodeContentDisposition(filename) {
// Encode filename according to RFC 5987 if necessary. Note,
// encodeURIComponent will percent-encode a superset of attr-char.
let encodedFilename = encodeURIComponent(filename);
if (encodedFilename === filename) {
return "attachment; filename=\"" + filename + "\"";
} else {
return "attachment; filename*=UTF-8''" + encodedFilename;
}
}
export default class Composer extends Component { export default class Composer extends Component {
state = { state = {
text: "", text: "",
@ -24,9 +13,6 @@ export default class Composer extends Component {
this.handleInput = this.handleInput.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.handleInputKeyDown = this.handleInputKeyDown.bind(this); this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
this.handleInputPaste = this.handleInputPaste.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this); this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
this.handleWindowPaste = this.handleWindowPaste.bind(this); this.handleWindowPaste = this.handleWindowPaste.bind(this);
} }
@ -130,119 +116,11 @@ export default class Composer extends Component {
this.setState({ text: autocomplete.text }); this.setState({ text: autocomplete.text });
} }
canUploadFiles() {
let client = this.props.client;
return client && client.isupport.filehost() && !this.props.readOnly;
}
async uploadFile(file) {
let client = this.props.client;
let endpoint = client.isupport.filehost();
let auth;
if (client.params.saslPlain) {
let params = client.params.saslPlain;
auth = "Basic " + btoa(params.username + ":" + params.password);
} else if (client.params.saslOauthBearer) {
auth = "Bearer " + client.params.saslOauthBearer.token;
}
let headers = {
"Content-Length": file.size,
"Content-Disposition": encodeContentDisposition(file.name),
};
if (file.type) {
headers["Content-Type"] = file.type;
}
if (auth) {
headers["Authorization"] = auth;
}
// TODO: show a loading UI while uploading
// TODO: show a cancel button
let resp = await fetch(endpoint, {
method: "POST",
body: file,
headers,
credentials: "include",
});
if (!resp.ok) {
throw new Error(`HTTP request failed (${resp.status})`);
}
let loc = resp.headers.get("Location");
if (!loc) {
throw new Error("filehost response missing Location header field");
}
return new URL(loc, endpoint);
}
async uploadFileList(fileList) {
let promises = [];
for (let file of fileList) {
promises.push(this.uploadFile(file));
}
let urls = await Promise.all(promises);
this.setState((state) => {
if (state.text) {
return { text: state.text + " " + urls.join(" ") };
} else {
return { text: urls.join(" ") };
}
});
}
async handleInputPaste(event) {
if (event.clipboardData.files.length === 0 || !this.canUploadFiles()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
await this.uploadFileList(event.clipboardData.files);
}
handleDragOver(event) {
if (event.dataTransfer.items.length === 0 || !this.canUploadFiles()) {
return;
}
for (let item of event.dataTransfer.items) {
if (item.kind !== "file") {
return;
}
}
event.preventDefault();
}
async handleDrop(event) {
if (event.dataTransfer.files.length === 0 || !this.canUploadFiles()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
await this.uploadFileList(event.dataTransfer.files);
}
handleWindowKeyDown(event) { handleWindowKeyDown(event) {
// If an <input> or <button> is focused, ignore. // If an <input> or <button> is focused, ignore.
if (document.activeElement && document.activeElement !== document.body) { if (document.activeElement !== document.body && document.activeElement.tagName !== "SECTION") {
switch (document.activeElement.tagName.toLowerCase()) {
case "section":
case "a":
break;
default:
return; return;
} }
}
// If a modifier is pressed, reserve for key bindings. // If a modifier is pressed, reserve for key bindings.
if (event.altKey || event.ctrlKey || event.metaKey) { if (event.altKey || event.ctrlKey || event.metaKey) {
@ -265,7 +143,7 @@ export default class Composer extends Component {
return; return;
} }
if (this.props.readOnly || (this.props.commandOnly && event.key !== "/")) { if (this.props.readOnly && event.key !== "/") {
return; return;
} }
@ -289,12 +167,7 @@ export default class Composer extends Component {
return; return;
} }
if (event.clipboardData.files.length > 0) { let text = event.clipboardData.getData('text');
this.handleInputPaste(event);
return;
}
let text = event.clipboardData.getData("text");
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
@ -328,11 +201,6 @@ export default class Composer extends Component {
className = "read-only"; className = "read-only";
} }
let placeholder = "Type a message";
if (this.props.commandOnly) {
placeholder = "Type a command (see /help)";
}
return html` return html`
<form <form
id="composer" id="composer"
@ -346,13 +214,9 @@ export default class Composer extends Component {
ref=${this.textInput} ref=${this.textInput}
value=${this.state.text} value=${this.state.text}
autocomplete="off" autocomplete="off"
placeholder=${placeholder} placeholder="Type a message"
enterkeyhint="send" enterkeyhint="send"
onKeyDown=${this.handleInputKeyDown} onKeyDown=${this.handleInputKeyDown}
onPaste=${this.handleInputPaste}
onDragOver=${this.handleDragOver}
onDrop=${this.handleDrop}
maxlength=${this.props.maxLen}
/> />
</form> </form>
`; `;

View File

@ -17,7 +17,7 @@ export default class ConnectForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleInput = this.handleInput.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.params) { if (props.params) {
@ -32,9 +32,9 @@ export default class ConnectForm extends Component {
} }
} }
handleInput(event) { handleChange(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@ -63,8 +63,6 @@ export default class ConnectForm extends Component {
}; };
} else if (this.props.auth === "external") { } else if (this.props.auth === "external") {
params.saslExternal = true; params.saslExternal = true;
} else if (this.props.auth === "oauth2") {
params.saslOauthBearer = this.props.params.saslOauthBearer;
} }
if (this.state.autojoin) { if (this.state.autojoin) {
@ -112,7 +110,7 @@ export default class ConnectForm extends Component {
} }
let auth = null; let auth = null;
if (this.props.auth !== "disabled" && this.props.auth !== "external" && this.props.auth !== "oauth2") { if (this.props.auth !== "disabled" && this.props.auth !== "external") {
auth = html` auth = html`
<label> <label>
Password:<br/> Password:<br/>
@ -140,14 +138,14 @@ export default class ConnectForm extends Component {
name="autojoin" name="autojoin"
checked=${this.state.autojoin} checked=${this.state.autojoin}
/> />
Auto-join channel${s} <strong>${channels.join(", ")}</strong> Auto-join channel${s} <strong>${channels.join(', ')}</strong>
</label> </label>
<br/><br/> <br/><br/>
`; `;
} }
return html` return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}> <form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<h2>Connect to IRC</h2> <h2>Connect to IRC</h2>
<label> <label>
@ -159,7 +157,6 @@ export default class ConnectForm extends Component {
disabled=${disabled} disabled=${disabled}
ref=${this.nickInput} ref=${this.nickInput}
required required
autofocus
/> />
</label> </label>
<br/><br/> <br/><br/>
@ -213,7 +210,7 @@ export default class ConnectForm extends Component {
<label> <label>
Server password:<br/> Server password:<br/>
<input <input
type="password" type="text"
name="pass" name="pass"
value=${this.state.pass} value=${this.state.pass}
disabled=${disabled} disabled=${disabled}

View File

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

View File

@ -1,4 +1,4 @@
import { html } from "../lib/index.js"; import { html, Component } from "../lib/index.js";
import { keybindings } from "../keybindings.js"; import { keybindings } from "../keybindings.js";
import commands from "../commands.js"; import commands from "../commands.js";
@ -6,7 +6,7 @@ function KeyBindingsHelp() {
let l = keybindings.map((binding) => { let l = keybindings.map((binding) => {
let keys = []; let keys = [];
if (binding.ctrlKey) { if (binding.ctrlKey) {
keys.push("Ctrl"); keys.psuh("Ctrl");
} }
if (binding.altKey) { if (binding.altKey) {
keys.push("Alt"); keys.push("Alt");
@ -26,32 +26,27 @@ function KeyBindingsHelp() {
`; `;
}); });
l.push(html` return html`
<dt><kbd>Tab</kbd></dt> <dl>
<dd>Automatically complete nickname or channel</dd> <dt><kbd>/</kbd></dt>
`); <dd>Start writing a command</dd>
if (!window.matchMedia("(pointer: none)").matches) { ${l}
l.push(html` </dl>
<dt><strong>Middle mouse click</strong></dt> `;
<dd>Close buffer</dd>
`);
}
return html`<dl>${l}</dl>`;
} }
function CommandsHelp() { function CommandsHelp() {
let l = [...commands.keys()].map((name) => { let l = Object.keys(commands).map((name) => {
let cmd = commands.get(name); let cmd = commands[name];
let usage = [html`<strong>/${name}</strong>`]; let usage = "/" + name;
if (cmd.usage) { if (cmd.usage) {
usage.push(" " + cmd.usage); usage += " " + cmd.usage;
} }
return html` return html`
<dt><code>${usage}</code></dt> <dt><strong><code>${usage}</code></strong></dt>
<dd>${cmd.description}</dd> <dd>${cmd.description}</dd>
`; `;
}); });

View File

@ -8,7 +8,7 @@ export default class JoinForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleInput = this.handleInput.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.channel) { if (props.channel) {
@ -16,9 +16,9 @@ export default class JoinForm extends Component {
} }
} }
handleInput(event) { handleChange(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@ -34,7 +34,7 @@ export default class JoinForm extends Component {
render() { render() {
return html` return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}> <form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label> <label>
Channel:<br/> Channel:<br/>
<input type="text" name="channel" value=${this.state.channel} autofocus required/> <input type="text" name="channel" value=${this.state.channel} autofocus required/>

View File

@ -1,4 +1,5 @@
import { html, Component } from "../lib/index.js"; import { html, Component } from "../lib/index.js";
import { getNickURL } from "../state.js";
import { strip as stripANSI } from "../lib/ansi.js"; import { strip as stripANSI } from "../lib/ansi.js";
import Membership from "./membership.js"; import Membership from "./membership.js";
import * as irc from "../lib/irc.js"; import * as irc from "../lib/irc.js";
@ -22,7 +23,26 @@ class MemberItem extends Component {
} }
render() { render() {
let title; // XXX: If we were feeling creative we could generate unique colors for
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const membmap = {
"~": "owner",
"&": "admin",
"@": "op",
"%": "halfop",
"+": "voice",
};
const membclass = membmap[this.props.membership[0]] || "";
let membership = "";
if (this.props.membership) {
membership = html`
<span class="membership ${membclass}" title=${membclass}>
${this.props.membership}
</span>
`;
};
let title = null;
let user = this.props.user; let user = this.props.user;
let classes = ["nick"]; let classes = ["nick"];
if (user) { if (user) {
@ -53,7 +73,7 @@ class MemberItem extends Component {
return html` return html`
<li> <li>
<a <a
href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })} href=${getNickURL(this.props.nick)}
class=${classes.join(" ")} class=${classes.join(" ")}
title=${title} title=${title}
onClick=${this.handleClick} onClick=${this.handleClick}
@ -81,7 +101,7 @@ function sortMembers(a, b) {
return i - j; return i - j;
} }
return nickA.localeCompare(nickB); return nickA < nickB ? -1 : 1;
} }
export default class MemberList extends Component { export default class MemberList extends Component {

View File

@ -1,14 +1,21 @@
import { html } from "../lib/index.js"; import { html, Component } from "../lib/index.js";
import * as irc from "../lib/irc.js";
// XXX: If we were feeling creative we could generate unique colors for
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const names = {
"~": "owner",
"&": "admin",
"@": "op",
"%": "halfop",
"+": "voice",
};
export default function Membership(props) { export default function Membership(props) {
if (!this.props.value) { if (!this.props.value) {
return null; return null;
} }
// XXX: If we were feeling creative we could generate unique colors for const name = names[this.props.value[0]] || "";
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const name = irc.STD_MEMBERSHIP_NAMES[this.props.value[0]] || "";
return html` return html`
<span class="membership ${name}" title=${name}> <span class="membership ${name}" title=${name}>
${this.props.value} ${this.props.value}

View File

@ -22,7 +22,7 @@ export default class NetworkForm extends Component {
this.prevParams = { ...defaultParams }; this.prevParams = { ...defaultParams };
this.handleInput = this.handleInput.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.params) { if (props.params) {
@ -35,9 +35,9 @@ export default class NetworkForm extends Component {
} }
} }
handleInput(event) { handleChange(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@ -46,10 +46,10 @@ export default class NetworkForm extends Component {
let params = {}; let params = {};
Object.keys(defaultParams).forEach((k) => { Object.keys(defaultParams).forEach((k) => {
if (!this.props.isNew && this.prevParams[k] === this.state[k]) { if (!this.props.isNew && this.prevParams[k] == this.state[k]) {
return; return;
} }
if (this.props.isNew && defaultParams[k] === this.state[k]) { if (this.props.isNew && defaultParams[k] == this.state[k]) {
return; return;
} }
params[k] = this.state[k]; params[k] = this.state[k];
@ -85,7 +85,7 @@ export default class NetworkForm extends Component {
} }
return html` return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}> <form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label> <label>
Hostname:<br/> Hostname:<br/>
<input type="text" name="host" value=${this.state.host} autofocus required/> <input type="text" name="host" value=${this.state.host} autofocus required/>

View File

@ -9,13 +9,13 @@ export default class RegisterForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleInput = this.handleInput.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
handleInput(event) { handleChange(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
render() { render() {
return html` return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}> <form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label> <label>
E-mail:<br/> E-mail:<br/>
<input <input

View File

@ -1,4 +1,4 @@
import { Component } from "../lib/index.js"; import { html, Component } from "../lib/index.js";
let store = new Map(); let store = new Map();
@ -11,10 +11,10 @@ export default class ScrollManager extends Component {
isAtBottom() { isAtBottom() {
let target = this.props.target.current; let target = this.props.target.current;
return Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 10; return target.scrollTop >= target.scrollHeight - target.offsetHeight;
} }
saveScrollPosition(scrollKey) { saveScrollPosition() {
let target = this.props.target.current; let target = this.props.target.current;
let sticky = target.querySelectorAll(this.props.stickTo); let sticky = target.querySelectorAll(this.props.stickTo);
@ -29,14 +29,11 @@ export default class ScrollManager extends Component {
} }
} }
store.set(scrollKey, stickToKey); store.set(this.props.scrollKey, stickToKey);
} }
restoreScrollPosition() { restoreScrollPosition() {
let target = this.props.target.current; let target = this.props.target.current;
if (!target.firstChild) {
return;
}
let stickToKey = store.get(this.props.scrollKey); let stickToKey = store.get(this.props.scrollKey);
if (!stickToKey) { if (!stickToKey) {
@ -48,13 +45,13 @@ export default class ScrollManager extends Component {
} }
} }
if (target.scrollTop === 0) { if (target.scrollTop == 0) {
this.props.onScrollTop(); this.props.onScrollTop();
} }
} }
handleScroll() { handleScroll() {
if (this.props.target.current.scrollTop === 0) { if (this.props.target.current.scrollTop == 0) {
this.props.onScrollTop(); this.props.onScrollTop();
} }
} }
@ -64,9 +61,9 @@ export default class ScrollManager extends Component {
this.props.target.current.addEventListener("scroll", this.handleScroll); this.props.target.current.addEventListener("scroll", this.handleScroll);
} }
getSnapshotBeforeUpdate(prevProps) { componentWillReceiveProps(nextProps) {
if (this.props.scrollKey !== prevProps.scrollKey || this.props.children !== prevProps.children) { if (this.props.scrollKey !== nextProps.scrollKey || this.props.children !== nextProps.children) {
this.saveScrollPosition(prevProps.scrollKey); this.saveScrollPosition();
} }
} }
@ -79,7 +76,7 @@ export default class ScrollManager extends Component {
componentWillUnmount() { componentWillUnmount() {
this.props.target.current.removeEventListener("scroll", this.handleScroll); this.props.target.current.removeEventListener("scroll", this.handleScroll);
this.saveScrollPosition(this.props.scrollKey); this.saveScrollPosition();
} }
render() { render() {

View File

@ -1,112 +0,0 @@
import { html, Component } from "../lib/index.js";
export default class SettingsForm extends Component {
state = {};
constructor(props) {
super(props);
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
this.state.bufferEvents = props.settings.bufferEvents;
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleInput(event) {
let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }, () => {
this.props.onChange(this.state);
});
}
handleSubmit(event) {
event.preventDefault();
this.props.onClose();
}
registerProtocol() {
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);
}
}
render() {
let protocolHandler = null;
if (this.props.showProtocolHandler) {
protocolHandler = html`
<div class="protocol-handler">
<div class="left">
Set gamja as your default IRC client for this browser.
IRC links will be automatically opened here.
</div>
<div class="right">
<button type="button" onClick=${() => this.registerProtocol()}>
Enable
</button>
</div>
</div>
<br/><br/>
`;
}
return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label>
<input
type="checkbox"
name="secondsInTimestamps"
checked=${this.state.secondsInTimestamps}
/>
Show seconds in time indicator
</label>
<br/><br/>
<label>
<input
type="radio"
name="bufferEvents"
value="fold"
checked=${this.state.bufferEvents === "fold"}
/>
Show and fold chat events
</label>
<br/>
<label>
<input
type="radio"
name="bufferEvents"
value="expand"
checked=${this.state.bufferEvents === "expand"}
/>
Show and expand chat events
</label>
<br/>
<label>
<input
type="radio"
name="bufferEvents"
value="hide"
checked=${this.state.bufferEvents === "hide"}
/>
Hide chat events
</label>
<br/><br/>
${protocolHandler}
<button type="button" class="danger" onClick=${() => this.props.onDisconnect()}>
Disconnect
</button>
<button>
Close
</button>
</form>
`;
}
}

View File

@ -1,171 +0,0 @@
import { html, Component } from "../lib/index.js";
import { BufferType, getBufferURL, getServerName } from "../state.js";
import * as irc from "../lib/irc.js";
class SwitcherItem extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(event) {
event.preventDefault();
this.props.onClick();
}
render() {
let class_ = this.props.selected ? "selected" : "";
return html`
<li>
<a
href=${getBufferURL(this.props.buffer)}
class=${class_}
onClick=${this.handleClick}
>
<span class="server">
${getServerName(this.props.server, this.props.bouncerNetwork)}
</span>
${this.props.buffer.name}
</a>
</li>
`;
}
}
function matchString(s, query) {
return s.toLowerCase().includes(query) ? 1 : 0;
}
function matchBuffer(buf, server, query) {
let score = 2 * matchString(buf.name, query);
switch (buf.type) {
case BufferType.CHANNEL:
score += matchString(buf.topic || "", query);
break;
case BufferType.NICK:
let user = server.users.get(buf.name);
if (user && user.realname && irc.isMeaningfulRealname(user.realname, buf.name)) {
score += matchString(user.realname, query);
}
break;
}
return score;
}
export default class SwitcherForm extends Component {
state = {
query: "",
selected: 0,
};
constructor(props) {
super(props);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
}
getSuggestions() {
let query = this.state.query.toLowerCase();
let l = [];
let scores = new Map();
for (let buf of this.props.buffers.values()) {
if (buf.type === BufferType.SERVER) {
continue;
}
let score = 0;
if (query !== "") {
let server = this.props.servers.get(buf.server);
score = matchBuffer(buf, server, query);
if (!score) {
continue;
}
}
scores.set(buf.id, score);
l.push(buf);
}
l.sort((a, b) => {
return scores.get(b.id) - scores.get(a.id);
});
return l.slice(0, 20);
}
handleInput(event) {
let target = event.target;
this.setState({ [target.name]: target.value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.getSuggestions()[this.state.selected]);
}
handleKeyUp(event) {
switch (event.key) {
case "ArrowUp":
event.stopPropagation();
this.move(-1);
break;
case "ArrowDown":
event.stopPropagation();
this.move(1);
break;
}
}
move(delta) {
let numSuggestions = this.getSuggestions().length;
this.setState((state) => {
return {
selected: (state.selected + delta + numSuggestions) % numSuggestions,
};
});
}
render() {
let items = this.getSuggestions().map((buf, i) => {
let server = this.props.servers.get(buf.server);
let bouncerNetwork = null;
if (server.bouncerNetID) {
bouncerNetwork = this.props.bouncerNetworks.get(server.bouncerNetID);
}
return html`
<${SwitcherItem}
buffer=${buf}
server=${server}
bouncerNetwork=${bouncerNetwork}
selected=${this.state.selected === i}
onClick=${() => this.props.onSubmit(buf)}
/>
`;
});
return html`
<form
onInput=${this.handleInput}
onSubmit=${this.handleSubmit}
onKeyUp=${this.handleKeyUp}
>
<input
type="search"
name="query"
value=${this.state.query}
placeholder="Filter"
autocomplete="off"
autofocus
/>
<ul class="switcher-list">
${items}
</ul>
</form>
`;
}
}

View File

@ -9,13 +9,13 @@ export default class RegisterForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleInput = this.handleInput.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
handleInput(event) { handleChange(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
render() { render() {
return html` return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}> <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>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> <p>${linkify(this.props.message)}</p>

View File

@ -63,8 +63,7 @@ if (remoteHost) {
ws.close(); ws.close();
}); });
client.on("error", (err) => { client.on("error", () => {
console.log(err);
ws.close(WS_BAD_GATEWAY); ws.close(WS_BAD_GATEWAY);
}); });
}); });

View File

@ -1,50 +0,0 @@
# Configuration file
gamja can be configured using a `config.json` file at the root. Example:
```json
{
"server": {
"url": "wss://irc.example.org",
"autojoin": "#gamja"
},
"oauth2": {
"url": "https://auth.example.org",
"client_id": "asdf"
}
}
```
Errors while parsing the configuration file are logged in the
[browser's web console].
## IRC server
The `server` object configures the IRC server.
- `url` (string): WebSocket URL or path to connect to. Defaults to `/socket`.
- `autojoin` (string or array of strings): Channel(s) to automatically join
after connecting.
- `auth` (string): configure how the password UI is presented to the user. Set
to `mandatory` to require a password, `optional` to accept one but not
require it, `disabled` to never ask for a password, `external` to use SASL
EXTERNAL, `oauth2` to use SASL OAUTHBEARER. Defaults to `optional`.
- `nick` (string): default nickname. If it contains a `*` character, it will be
replaced with a random string.
- `autoconnect` (boolean): don't display the login UI, immediately connect to
the server
- `ping` (number): interval in seconds to send PING commands. Set to 0 to
disable, this is the default. Enabling PINGs can have an impact on client
power usage and should only be enabled if necessary.
## OAuth 2.0
The `oauth2` object configures OAuth 2.0 authentication.
- `url` (string): OAuth 2.0 server URL. The server must support OAuth 2.0
Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery.
- `client_id` (string): OAuth 2.0 client ID.
- `client_secret` (string): OAuth 2.0 client secret.
- `scope` (string): OAuth 2.0 scope.
[browser's web console]: https://firefox-source-docs.mozilla.org/devtools-user/web_console/index.html

View File

@ -1,68 +0,0 @@
# Setting up gamja
An HTTP server must be configured to serve the gamja static files. Usually,
the same HTTP server is used as a reverse proxy for the IRC WebSocket.
## [soju]
Add a WebSocket listener to soju, e.g. `listen ws+insecure://127.0.0.1:8080`.
Then configure your reverse proxy to serve gamja files and proxy `/socket` to
soju.
## [webircgateway]
Setup webircgateway to serve gamja files:
```ini
[fileserving]
enabled = true
webroot = /path/to/gamja
```
Then configure gamja to connect to `/webirc/websocket/` (either by setting
`server.url` in the [configuration file], or by appending
`?server=/webirc/websocket/` to the URL).
## nginx
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
timeout to a value higher than the IRC server PING interval. Example:
```
location / {
root /path/to/gamja;
}
location /socket {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
If you are unable to configure the proxy timeout accordingly, or if your IRC
server doesn't send PINGs, you can set the `server.ping` option in
`config.json` (see below).
## [kimchi]
Setup kimchi to serve gamja files and proxy the WebSocket connection:
```
site irc.example.org {
file_server /path/to/gamja
}
site irc.example.org/socket {
reverse_proxy http://127.0.0.1:8080
}
```
[soju]: https://soju.im
[webircgateway]: https://github.com/kiwiirc/webircgateway
[kimchi]: https://sr.ht/~emersion/kimchi/
[configuration file]: config-file.md

View File

@ -1,15 +0,0 @@
# URL parameters
gamja settings can be overridden using URL query parameters:
- `server`: path or URL to the WebSocket server
- `nick`: nickname (if the character `*` appears in the string, it will be
replaced with a randomly generated value)
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open
- `debug`: enable debug logs if set to `1`, disable debug logs if set to `0`
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04

View File

@ -1,56 +0,0 @@
import globals from "globals";
import js from "@eslint/js";
import stylisticJs from "@stylistic/eslint-plugin-js";
export default [
{
ignores: ["dist/"],
},
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
"process": "readonly",
},
},
plugins: { "@stylistic/js": stylisticJs },
rules: {
"no-case-declarations": "off",
"no-unused-vars": ["error", {
args: "none",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
}],
"no-var": "error",
"no-eval": "error",
"no-implied-eval": "error",
"eqeqeq": "error",
"no-invalid-this": "error",
"no-extend-native": "error",
"prefer-arrow-callback": "error",
"no-implicit-globals": "error",
"no-throw-literal": "error",
"no-implicit-coercion": "warn",
"object-shorthand": "warn",
"curly": "warn",
"camelcase": "warn",
"@stylistic/js/indent": ["warn", "tab"],
"@stylistic/js/quotes": ["warn", "double"],
"@stylistic/js/semi": "warn",
"@stylistic/js/brace-style": ["warn", "1tbs"],
"@stylistic/js/comma-dangle": ["warn", "always-multiline"],
"@stylistic/js/comma-spacing": "warn",
"@stylistic/js/arrow-parens": "warn",
"@stylistic/js/arrow-spacing": "warn",
"@stylistic/js/block-spacing": "warn",
"@stylistic/js/object-curly-spacing": ["warn", "always"],
"@stylistic/js/object-curly-newline": ["warn", {
multiline: true,
consistent: true,
}],
"@stylistic/js/array-bracket-spacing": ["warn", "never"],
"@stylistic/js/array-bracket-newline": ["warn", "consistent"],
},
},
];

View File

@ -1,4 +1,4 @@
import { ReceiptType, Unread, BufferType, receiptFromMessage } from "./state.js"; import { ReceiptType, Unread, BufferType, SERVER_BUFFER } from "./state.js";
function getSiblingBuffer(buffers, bufID, delta) { function getSiblingBuffer(buffers, bufID, delta) {
let bufList = Array.from(buffers.values()); let bufList = Array.from(buffers.values());
@ -19,29 +19,25 @@ export const keybindings = [
app.setState((state) => { app.setState((state) => {
let buffers = new Map(); let buffers = new Map();
state.buffers.forEach((buf) => { state.buffers.forEach((buf) => {
if (buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
app.setReceipt(buf.name, ReceiptType.READ, lastMsg);
}
buffers.set(buf.id, { buffers.set(buf.id, {
...buf, ...buf,
unread: Unread.NONE, unread: Unread.NONE,
prevReadReceipt: null, prevReadReceipt: null,
}); });
let receipts = {};
if (buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
receipts[ReceiptType.READ] = receiptFromMessage(lastMsg);
}
let client = app.clients.get(buf.server); let client = app.clients.get(buf.server);
app.bufferStore.put({ app.bufferStore.put({
name: buf.name, name: buf.name,
server: client.params, server: client.params,
unread: Unread.NONE, unread: Unread.NONE,
receipts,
}); });
}); });
return { buffers }; return { buffers };
}, () => {
app.updateDocumentTitle();
}); });
}, },
}, },
@ -96,14 +92,6 @@ export const keybindings = [
} }
}, },
}, },
{
key: "k",
ctrlKey: true,
description: "Switch to a buffer",
execute: (app) => {
app.openDialog("switch");
},
},
]; ];
export function setup(app) { export function setup(app) {
@ -121,9 +109,9 @@ export function setup(app) {
return; return;
} }
candidates = candidates.filter((binding) => { candidates = candidates.filter((binding) => {
return Boolean(binding.altKey) === event.altKey && Boolean(binding.ctrlKey) === event.ctrlKey; return !!binding.altKey == event.altKey && !!binding.ctrlKey == event.ctrlKey;
}); });
if (candidates.length !== 1) { if (candidates.length != 1) {
return; return;
} }
event.preventDefault(); event.preventDefault();

View File

@ -10,26 +10,10 @@ const COLOR_HEX = "\x04";
const REVERSE_COLOR = "\x16"; const REVERSE_COLOR = "\x16";
const RESET = "\x0F"; const RESET = "\x0F";
const HEX_COLOR_LENGTH = 6;
function isDigit(ch) { function isDigit(ch) {
return ch >= "0" && ch <= "9"; return ch >= "0" && ch <= "9";
} }
function isHexColor(text) {
if (text.length < HEX_COLOR_LENGTH) {
return false;
}
for (let i = 0; i < HEX_COLOR_LENGTH; i++) {
let ch = text[i].toUpperCase();
let ok = (ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "F");
if (!ok) {
return false;
}
}
return true;
}
export function strip(text) { export function strip(text) {
let out = ""; let out = "";
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
@ -51,7 +35,7 @@ export function strip(text) {
if (isDigit(text[i + 1])) { if (isDigit(text[i + 1])) {
i++; i++;
} }
if (text[i + 1] === "," && isDigit(text[i + 2])) { if (text[i + 1] == "," && isDigit(text[i + 2])) {
i += 2; i += 2;
if (isDigit(text[i + 1])) { if (isDigit(text[i + 1])) {
i++; i++;
@ -59,13 +43,7 @@ export function strip(text) {
} }
break; break;
case COLOR_HEX: case COLOR_HEX:
if (!isHexColor(text.slice(i + 1))) { i += 6;
break;
}
i += HEX_COLOR_LENGTH;
if (text[i + 1] === "," && isHexColor(text.slice(i + 2))) {
i += 1 + HEX_COLOR_LENGTH;
}
break; break;
default: default:
out += ch; out += ch;

View File

@ -1,42 +0,0 @@
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/* The JS world is still in the stone age. We're in 2022 and we still don't
* have the technology to correctly base64-encode a UTF-8 string. Can't wait
* the next industrial revolution.
*
* For more info, see:
* https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
*/
export function encode(data) {
if (!window.TextEncoder) {
return btoa(data);
}
let encoder = new TextEncoder();
let bytes = encoder.encode(data);
let trailing = bytes.length % 3;
let out = "";
for (let i = 0; i < bytes.length - trailing; i += 3) {
let u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += alphabet[(u24 >> 18) & 0x3F];
out += alphabet[(u24 >> 12) & 0x3F];
out += alphabet[(u24 >> 6) & 0x3F];
out += alphabet[u24 & 0x3F];
}
if (trailing === 1) {
let u8 = bytes[bytes.length - 1];
out += alphabet[u8 >> 2];
out += alphabet[(u8 << 4) & 0x3F];
out += "==";
} else if (trailing === 2) {
let u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
out += alphabet[u16 >> 10];
out += alphabet[(u16 >> 4) & 0x3F];
out += alphabet[(u16 << 2) & 0x3F];
out += "=";
}
return out;
}

View File

@ -9,7 +9,6 @@ const permanentCaps = [
"chghost", "chghost",
"echo-message", "echo-message",
"extended-join", "extended-join",
"extended-monitor",
"invite-notify", "invite-notify",
"labeled-response", "labeled-response",
"message-tags", "message-tags",
@ -20,9 +19,8 @@ const permanentCaps = [
"draft/account-registration", "draft/account-registration",
"draft/chathistory", "draft/chathistory",
"draft/event-playback",
"draft/extended-monitor", "draft/extended-monitor",
"draft/message-redaction",
"draft/read-marker",
"soju.im/bouncer-networks", "soju.im/bouncer-networks",
]; ];
@ -49,8 +47,6 @@ const WHOX_FIELDS = {
"realname": "r", "realname": "r",
}; };
const FALLBACK_SERVER_PREFIX = { name: "*" };
let lastLabel = 0; let lastLabel = 0;
let lastWhoxToken = 0; let lastWhoxToken = 0;
@ -75,8 +71,8 @@ class IRCError extends Error {
class Backoff { class Backoff {
n = 0; n = 0;
constructor(base, max) { constructor(min, max) {
this.base = base; this.min = min;
this.max = max; this.max = max;
} }
@ -87,10 +83,10 @@ class Backoff {
next() { next() {
if (this.n === 0) { if (this.n === 0) {
this.n = 1; this.n = 1;
return 0; return this.min;
} }
let dur = this.n * this.base; let dur = this.n * this.min;
if (dur > this.max) { if (dur > this.max) {
dur = this.max; dur = this.max;
} else { } else {
@ -110,7 +106,7 @@ export default class Client extends EventTarget {
}; };
status = Client.Status.DISCONNECTED; status = Client.Status.DISCONNECTED;
serverPrefix = FALLBACK_SERVER_PREFIX; serverPrefix = { name: "*" };
nick = null; nick = null;
supportsCap = false; supportsCap = false;
caps = new irc.CapRegistry(); caps = new irc.CapRegistry();
@ -125,17 +121,13 @@ export default class Client extends EventTarget {
pass: null, pass: null,
saslPlain: null, saslPlain: null,
saslExternal: false, saslExternal: false,
saslOauthBearer: null,
bouncerNetwork: null, bouncerNetwork: null,
ping: 0,
eventPlayback: true,
}; };
debug = false; 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); reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
lastReconnectDate = new Date(0);
pingIntervalID = null; pingIntervalID = null;
pendingCmds = { pendingCmds = {
WHO: Promise.resolve(null), WHO: Promise.resolve(null),
@ -149,8 +141,6 @@ export default class Client extends EventTarget {
constructor(params) { constructor(params) {
super(); super();
this.handleOnline = this.handleOnline.bind(this);
this.params = { ...this.params, ...params }; this.params = { ...this.params, ...params };
this.reconnect(); this.reconnect();
@ -163,7 +153,6 @@ export default class Client extends EventTarget {
console.log("Connecting to " + this.params.url); console.log("Connecting to " + this.params.url);
this.setStatus(Client.Status.CONNECTING); this.setStatus(Client.Status.CONNECTING);
this.lastReconnectDate = new Date();
try { try {
this.ws = new WebSocket(this.params.url); this.ws = new WebSocket(this.params.url);
@ -196,7 +185,7 @@ export default class Client extends EventTarget {
this.ws = null; this.ws = null;
this.setStatus(Client.Status.DISCONNECTED); this.setStatus(Client.Status.DISCONNECTED);
this.nick = null; this.nick = null;
this.serverPrefix = FALLBACK_SERVER_PREFIX; this.serverPrefix = null;
this.caps = new irc.CapRegistry(); this.caps = new irc.CapRegistry();
this.batches = new Map(); this.batches = new Map();
Object.keys(this.pendingCmds).forEach((k) => { Object.keys(this.pendingCmds).forEach((k) => {
@ -206,16 +195,15 @@ export default class Client extends EventTarget {
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459); this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
if (this.autoReconnect) { if (this.autoReconnect) {
window.addEventListener("online", this.handleOnline);
if (!navigator.onLine) { if (!navigator.onLine) {
console.info("Waiting for network to go back online"); console.info("Waiting for network to go back online");
const handleOnline = () => {
window.removeEventListener("online", handleOnline);
this.reconnect();
};
window.addEventListener("online", handleOnline);
} else { } else {
let delay = this.reconnectBackoff.next(); let delay = this.reconnectBackoff.next();
let sinceLastReconnect = new Date().getTime() - this.lastReconnectDate.getTime();
if (sinceLastReconnect < RECONNECT_MIN_DELAY_MSEC) {
delay = Math.max(delay, RECONNECT_MIN_DELAY_MSEC);
}
console.info("Reconnecting to server in " + (delay / 1000) + " seconds"); console.info("Reconnecting to server in " + (delay / 1000) + " seconds");
clearTimeout(this.reconnectTimeoutID); clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = setTimeout(() => { this.reconnectTimeoutID = setTimeout(() => {
@ -232,8 +220,6 @@ export default class Client extends EventTarget {
clearTimeout(this.reconnectTimeoutID); clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = null; this.reconnectTimeoutID = null;
window.removeEventListener("online", this.handleOnline);
this.setPingInterval(0); this.setPingInterval(0);
if (this.ws) { if (this.ws) {
@ -253,19 +239,11 @@ export default class Client extends EventTarget {
this.dispatchEvent(new CustomEvent("error", { detail: err })); this.dispatchEvent(new CustomEvent("error", { detail: err }));
} }
handleOnline() {
window.removeEventListener("online", this.handleOnline);
if (this.autoReconnect && this.status === Client.Status.DISCONNECTED) {
this.reconnect();
}
}
handleOpen() { handleOpen() {
console.log("Connection opened"); console.log("Connection opened");
this.setStatus(Client.Status.REGISTERING); this.setStatus(Client.Status.REGISTERING);
this.reconnectBackoff.reset(); this.reconnectBackoff.reset();
this.setPingInterval(this.params.ping);
this.nick = this.params.nick; this.nick = this.params.nick;
@ -301,21 +279,16 @@ export default class Client extends EventTarget {
return; return;
} }
let raw = event.data; let msg = irc.parseMessage(event.data);
if (this.debug) { if (this.debug) {
console.debug("Received:", raw); console.debug("Received:", msg);
} }
let msg = irc.parseMessage(raw);
// If the prefix is missing, assume it's coming from the server on the // If the prefix is missing, assume it's coming from the server on the
// other end of the connection // other end of the connection
if (!msg.prefix) { if (!msg.prefix) {
msg.prefix = this.serverPrefix; msg.prefix = this.serverPrefix;
} }
if (!msg.tags) {
msg.tags = {};
}
let msgBatch = null; let msgBatch = null;
if (msg.tags["batch"]) { if (msg.tags["batch"]) {
@ -326,6 +299,7 @@ export default class Client extends EventTarget {
} }
let deleteBatch = null; let deleteBatch = null;
let k;
switch (msg.command) { switch (msg.command) {
case irc.RPL_WELCOME: case irc.RPL_WELCOME:
if (this.params.saslPlain && !this.supportsCap) { if (this.params.saslPlain && !this.supportsCap) {
@ -369,7 +343,7 @@ export default class Client extends EventTarget {
case "AUTHENTICATE": case "AUTHENTICATE":
// Both PLAIN and EXTERNAL expect an empty challenge // Both PLAIN and EXTERNAL expect an empty challenge
let challengeStr = msg.params[0]; let challengeStr = msg.params[0];
if (challengeStr !== "+") { if (challengeStr != "+") {
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr)); this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
this.send({ command: "AUTHENTICATE", params: ["*"] }); this.send({ command: "AUTHENTICATE", params: ["*"] });
} }
@ -440,7 +414,7 @@ export default class Client extends EventTarget {
case irc.ERR_NOPERMFORHOST: case irc.ERR_NOPERMFORHOST:
case irc.ERR_YOUREBANNEDCREEP: case irc.ERR_YOUREBANNEDCREEP:
this.dispatchError(new IRCError(msg)); this.dispatchError(new IRCError(msg));
if (this.status !== Client.Status.REGISTERED) { if (this.status != Client.Status.REGISTERED) {
this.disconnect(); this.disconnect();
} }
break; break;
@ -448,6 +422,7 @@ export default class Client extends EventTarget {
if (this.status === Client.Status.REGISTERED) { if (this.status === Client.Status.REGISTERED) {
break; break;
} }
let reason = msg.params[msg.params.length - 1];
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") { if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
this.dispatchError(new Error("Failed to bind to bouncer network", { this.dispatchError(new Error("Failed to bind to bouncer network", {
cause: new IRCError(msg), cause: new IRCError(msg),
@ -479,16 +454,14 @@ export default class Client extends EventTarget {
console.log(`Starting SASL ${mechanism} authentication`); console.log(`Starting SASL ${mechanism} authentication`);
// Send the first SASL response immediately to avoid a roundtrip // Send the first SASL response immediately to avoid a roundtrip
let initialResp; let initialResp = null;
switch (mechanism) { switch (mechanism) {
case "PLAIN": case "PLAIN":
initialResp = "\0" + params.username + "\0" + params.password; let respStr = btoa("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break; break;
case "EXTERNAL": case "EXTERNAL":
initialResp = ""; initialResp = { command: "AUTHENTICATE", params: [btoa("")] };
break;
case "OAUTHBEARER":
initialResp = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
break; break;
default: default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`); throw new Error(`Unknown authentication mechanism '${mechanism}'`);
@ -507,9 +480,7 @@ export default class Client extends EventTarget {
throw new IRCError(msg); throw new IRCError(msg);
} }
}); });
for (let msg of irc.generateAuthenticateMessages(initialResp)) { this.send(initialResp);
this.send(msg);
}
return promise; return promise;
} }
@ -543,19 +514,16 @@ export default class Client extends EventTarget {
return this.roundtrip(msg, (msg) => { return this.roundtrip(msg, (msg) => {
switch (msg.command) { switch (msg.command) {
case irc.RPL_WHOREPLY: case irc.RPL_WHOREPLY:
msg.internal = true;
l.push(this.parseWhoReply(msg)); l.push(this.parseWhoReply(msg));
break; break;
case irc.RPL_WHOSPCRPL: case irc.RPL_WHOSPCRPL:
if (msg.params.length !== fields.length + 1 || msg.params[1] !== token) { if (msg.params.length !== fields.length || msg.params[1] !== token) {
break; break;
} }
msg.internal = true;
l.push(this.parseWhoReply(msg)); l.push(this.parseWhoReply(msg));
break; break;
case irc.RPL_ENDOFWHO: case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) { if (msg.params[1] === mask) {
msg.internal = true;
return l; return l;
} }
break; break;
@ -653,9 +621,6 @@ export default class Client extends EventTarget {
if (!this.params.bouncerNetwork) { if (!this.params.bouncerNetwork) {
wantCaps.push("soju.im/bouncer-networks-notify"); wantCaps.push("soju.im/bouncer-networks-notify");
} }
if (this.params.eventPlayback) {
wantCaps.push("draft/event-playback");
}
let msg = this.caps.requestAvailable(wantCaps); let msg = this.caps.requestAvailable(wantCaps);
if (msg) { if (msg) {
@ -671,7 +636,7 @@ export default class Client extends EventTarget {
switch (subCmd) { switch (subCmd) {
case "LS": case "LS":
this.supportsCap = true; this.supportsCap = true;
if (args[0] === "*") { if (args[0] == "*") {
break; break;
} }
@ -686,8 +651,6 @@ export default class Client extends EventTarget {
promise = this.authenticate("PLAIN", this.params.saslPlain); promise = this.authenticate("PLAIN", this.params.saslPlain);
} else if (this.params.saslExternal) { } else if (this.params.saslExternal) {
promise = this.authenticate("EXTERNAL"); promise = this.authenticate("EXTERNAL");
} else if (this.params.saslOauthBearer) {
promise = this.authenticate("OAUTHBEARER", this.params.saslOauthBearer);
} }
(promise || Promise.resolve()).catch((err) => { (promise || Promise.resolve()).catch((err) => {
this.dispatchError(err); this.dispatchError(err);
@ -725,10 +688,9 @@ export default class Client extends EventTarget {
if (!this.ws) { if (!this.ws) {
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed"); throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
} }
let raw = irc.formatMessage(msg); this.ws.send(irc.formatMessage(msg));
this.ws.send(raw);
if (this.debug) { if (this.debug) {
console.debug("Sent:", raw); console.debug("Sent:", msg);
} }
} }
@ -743,7 +705,7 @@ export default class Client extends EventTarget {
} }
isMyNick(nick) { isMyNick(nick) {
return this.cm(nick) === this.cm(this.nick); return this.cm(nick) == this.cm(this.nick);
} }
isChannel(name) { isChannel(name) {
@ -751,11 +713,6 @@ export default class Client extends EventTarget {
return chanTypes.indexOf(name[0]) >= 0; return chanTypes.indexOf(name[0]) >= 0;
} }
isNick(name) {
// A dollar sign is used for server-wide broadcasts
return !this.isServer(name) && !this.isChannel(name) && !name.startsWith("$");
}
setPingInterval(sec) { setPingInterval(sec) {
clearInterval(this.pingIntervalID); clearInterval(this.pingIntervalID);
this.pingIntervalID = null; this.pingIntervalID = null;
@ -790,7 +747,7 @@ export default class Client extends EventTarget {
let msg = event.detail.message; let msg = event.detail.message;
let msgLabel = irc.getMessageLabel(msg); let msgLabel = irc.getMessageLabel(msg);
if (msgLabel && msgLabel !== label) { if (msgLabel && msgLabel != label) {
return; return;
} }
@ -839,22 +796,16 @@ export default class Client extends EventTarget {
this.removeEventListener("status", handleStatus); this.removeEventListener("status", handleStatus);
}; };
// Turn on capture to handle messages before external users and this.addEventListener("message", handleMessage);
// have the opportunity to set the "internal" flag
this.addEventListener("message", handleMessage, { capture: true });
this.addEventListener("status", handleStatus); this.addEventListener("status", handleStatus);
this.send(msg); this.send(msg);
}); });
} }
join(channel, password) { join(channel) {
let params = [channel];
if (password) {
params.push(password);
}
let msg = { let msg = {
command: "JOIN", command: "JOIN",
params, params: [channel],
}; };
return this.roundtrip(msg, (msg) => { return this.roundtrip(msg, (msg) => {
switch (msg.command) { switch (msg.command) {
@ -880,6 +831,7 @@ export default class Client extends EventTarget {
fetchBatch(msg, batchType) { fetchBatch(msg, batchType) {
let batchName = null; let batchName = null;
let messages = []; let messages = [];
let cmd = msg.command;
return this.roundtrip(msg, (msg) => { return this.roundtrip(msg, (msg) => {
if (batchName) { if (batchName) {
let batch = msg.batch; let batch = msg.batch;
@ -923,48 +875,53 @@ export default class Client extends EventTarget {
} }
/* Fetch one page of history before the given date. */ /* Fetch one page of history before the given date. */
async fetchHistoryBefore(target, before, limit) { fetchHistoryBefore(target, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory()); let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max]; let params = ["BEFORE", target, "timestamp=" + before, max];
let messages = await this.roundtripChatHistory(params); return this.roundtripChatHistory(params).then((messages) => {
return { messages, more: messages.length >= max }; return { more: messages.length >= max };
});
} }
/* Fetch history in ascending order. */ /* Fetch history in ascending order. */
async fetchHistoryBetween(target, after, before, limit) { fetchHistoryBetween(target, after, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory()); let max = Math.min(limit, this.isupport.chatHistory());
let params = ["AFTER", target, "timestamp=" + after.time, max]; let params = ["AFTER", target, "timestamp=" + after.time, max];
let messages = await this.roundtripChatHistory(params); return this.roundtripChatHistory(params).then((messages) => {
limit -= messages.length; limit -= messages.length;
if (limit <= 0) { if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages"); throw new Error("Cannot fetch all chat history: too many messages");
} }
if (messages.length >= max) { if (messages.length == max) {
// There are still more messages to fetch // There are still more messages to fetch
after = { ...after, time: messages[messages.length - 1].tags.time }; after.time = messages[messages.length - 1].tags.time;
return await this.fetchHistoryBetween(target, after, before, limit); return this.fetchHistoryBetween(target, after, before, limit);
} }
return { messages }; return null;
});
} }
async fetchHistoryTargets(t1, t2) { fetchHistoryTargets(t1, t2) {
let msg = { let msg = {
command: "CHATHISTORY", command: "CHATHISTORY",
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000], params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
}; };
let batch = await this.fetchBatch(msg, "draft/chathistory-targets"); return this.fetchBatch(msg, "draft/chathistory-targets").then((batch) => {
return batch.messages.map((msg) => { return batch.messages.map((msg) => {
console.assert(msg.command === "CHATHISTORY" && msg.params[0] === "TARGETS"); if (msg.command != "CHATHISTORY" || msg.params[0] != "TARGETS") {
throw new Error("Cannot fetch chat history targets: unexpected message " + msg);
}
return { return {
name: msg.params[1], name: msg.params[1],
latestMessage: msg.params[2], latestMessage: msg.params[2],
}; };
}); });
});
} }
async listBouncerNetworks() { listBouncerNetworks() {
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] }; let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
let batch = await this.fetchBatch(req, "soju.im/bouncer-networks"); return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
let networks = new Map(); let networks = new Map();
for (let msg of batch.messages) { for (let msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK"); console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
@ -973,6 +930,7 @@ export default class Client extends EventTarget {
networks.set(id, params); networks.set(id, params);
} }
return networks; return networks;
});
} }
monitor(target) { monitor(target) {
@ -1046,22 +1004,4 @@ export default class Client extends EventTarget {
return { message: msg.params[2] }; return { message: msg.params[2] };
}); });
} }
supportsReadMarker() {
return this.caps.enabled.has("draft/read-marker");
}
fetchReadMarker(target) {
this.send({
command: "MARKREAD",
params: [target],
});
}
setReadMarker(target, t) {
this.send({
command: "MARKREAD",
params: [target, "timestamp="+t],
});
}
} }

View File

@ -4,5 +4,5 @@ import { h } from "../node_modules/preact/dist/preact.module.js";
import htm from "../node_modules/htm/dist/htm.module.js"; import htm from "../node_modules/htm/dist/htm.module.js";
export const html = htm.bind(h); export const html = htm.bind(h);
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.es.js"; import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js";
export { linkifyjs }; export { linkifyjs };

View File

@ -1,5 +1,3 @@
import * as base64 from "./base64.js";
// RFC 1459 // RFC 1459
export const RPL_WELCOME = "001"; export const RPL_WELCOME = "001";
export const RPL_YOURHOST = "002"; export const RPL_YOURHOST = "002";
@ -54,7 +52,6 @@ export const ERR_BADCHANNELKEY = "475";
// RFC 2812 // RFC 2812
export const ERR_UNAVAILRESOURCE = "437"; export const ERR_UNAVAILRESOURCE = "437";
// Other // Other
export const RPL_CHANNEL_URL = "328";
export const RPL_CREATIONTIME = "329"; 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";
@ -74,24 +71,9 @@ export const ERR_SASLTOOLONG = "905";
export const ERR_SASLABORTED = "906"; export const ERR_SASLABORTED = "906";
export const ERR_SASLALREADY = "907"; export const ERR_SASLALREADY = "907";
export const STD_MEMBERSHIP_NAMES = { export const STD_MEMBERSHIPS = "~&@%+";
"~": "owner", export const STD_CHANTYPES = "#&+!";
"&": "admin", export const STD_CHANMODES = "beI,k,l,imnst";
"@": "operator",
"%": "halfop",
"+": "voice",
};
export const STD_MEMBERSHIP_MODES = {
"~": "q",
"&": "a",
"@": "o",
"%": "h",
"+": "v",
};
const STD_MEMBERSHIPS = "~&@%+";
const STD_CHANTYPES = "#&+!";
const tagEscapeMap = { const tagEscapeMap = {
";": "\\:", ";": "\\:",
@ -120,10 +102,10 @@ export function parseTags(s) {
let parts = s.split("=", 2); let parts = s.split("=", 2);
let k = parts[0]; let k = parts[0];
let v = null; let v = null;
if (parts.length === 2) { if (parts.length == 2) {
v = unescapeTag(parts[1]); v = unescapeTag(parts[1]);
if (v.endsWith("\\")) { if (v.endsWith("\\")) {
v = v.slice(0, v.length - 1); v = v.slice(0, v.length - 1)
} }
} }
tags[k] = v; tags[k] = v;
@ -145,6 +127,12 @@ export function formatTags(tags) {
} }
export function parsePrefix(s) { export function parsePrefix(s) {
let prefix = {
name: null,
user: null,
host: null,
};
let host = null; let host = null;
let i = s.indexOf("@"); let i = s.indexOf("@");
if (i > 0) { if (i > 0) {
@ -241,7 +229,7 @@ export function formatMessage(msg) {
s += msg.command; s += msg.command;
if (msg.params && msg.params.length > 0) { if (msg.params && msg.params.length > 0) {
for (let i = 0; i < msg.params.length - 1; i++) { for (let i = 0; i < msg.params.length - 1; i++) {
s += " " + msg.params[i]; s += " " + msg.params[i]
} }
let last = String(msg.params[msg.params.length - 1]); let last = String(msg.params[msg.params.length - 1]);
@ -271,12 +259,11 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
const alphaNum = (() => { const alphaNum = (() => {
try { try {
return new RegExp(/^[\p{L}0-9]$/, "u"); return new RegExp(/^\p{L}$/, "u");
} catch (_e) { } catch (e) {
return new RegExp(/^[a-zA-Z0-9]$/, "u"); return new RegExp(/^[a-zA-Z0-9]$/, "u");
} }
})(); })();
const space = new RegExp(/^\s$/);
function isWordBoundary(ch) { function isWordBoundary(ch) {
switch (ch) { switch (ch) {
@ -284,44 +271,21 @@ function isWordBoundary(ch) {
case "_": case "_":
case "|": case "|":
return false; return false;
case "\u00A0":
return true;
default: default:
return !alphaNum.test(ch); return !alphaNum.test(ch);
} }
} }
function isURIPrefix(text) {
for (let i = text.length - 1; i >= 0; i--) {
if (space.test(text[i])) {
text = text.slice(i);
break;
}
}
let i = text.indexOf("://");
if (i <= 0) {
return false;
}
// See RFC 3986 section 3
let ch = text[i - 1];
switch (ch) {
case "+":
case "-":
case ".":
return true;
default:
return alphaNum.test(ch);
}
}
export function isHighlight(msg, nick, cm) { export function isHighlight(msg, nick, cm) {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") { if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
return false; return false;
} }
nick = cm(nick); nick = cm(nick);
if (msg.prefix && cm(msg.prefix.name) === nick) { if (msg.prefix && cm(msg.prefix.name) == nick) {
return false; // Our own messages aren't highlights return false; // Our own messages aren't highlights
} }
@ -337,10 +301,10 @@ export function isHighlight(msg, nick, cm) {
if (i > 0) { if (i > 0) {
left = text[i - 1]; left = text[i - 1];
} }
if (i + nick.length < text.length) { if (i < text.length) {
right = text[i + nick.length]; right = text[i + nick.length];
} }
if (isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text.slice(0, i))) { if (isWordBoundary(left) && isWordBoundary(right)) {
return true; return true;
} }
@ -349,7 +313,7 @@ export function isHighlight(msg, nick, cm) {
} }
export function isServerBroadcast(msg) { export function isServerBroadcast(msg) {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") { if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
return false; return false;
} }
return msg.params[0].startsWith("$"); return msg.params[0].startsWith("$");
@ -387,7 +351,7 @@ export function formatDate(date) {
} }
export function parseCTCP(msg) { export function parseCTCP(msg) {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") { if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
return null; return null;
} }
@ -500,57 +464,6 @@ export class Isupport {
bouncerNetID() { bouncerNetID() {
return this.raw.get("BOUNCER_NETID"); return this.raw.get("BOUNCER_NETID");
} }
chanModes() {
const stdChanModes = ["beI", "k", "l", "imnst"];
if (!this.raw.has("CHANMODES")) {
return stdChanModes;
}
let chanModes = this.raw.get("CHANMODES").split(",");
if (chanModes.length !== 4) {
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
return stdChanModes;
}
return chanModes;
}
bot() {
return this.raw.get("BOT");
}
userLen() {
if (!this.raw.has("USERLEN")) {
return 20;
}
return parseInt(this.raw.get("USERLEN"), 10);
}
hostLen() {
if (!this.raw.has("HOSTLEN")) {
return 63;
}
return parseInt(this.raw.get("HOSTLEN"), 10);
}
lineLen() {
if (!this.raw.has("LINELEN")) {
return 512;
}
return parseInt(this.raw.get("LINELEN"), 10);
}
filehost() {
return this.raw.get("SOJU.IM/FILEHOST");
}
}
export function getMaxPrivmsgLen(isupport, nick, target) {
let user = "_".repeat(isupport.userLen());
let host = "_".repeat(isupport.hostLen());
let prefix = { name: nick, user, host };
let msg = { prefix, command: "PRIVMSG", params: [target, ""] };
let raw = formatMessage(msg) + "\r\n";
return isupport.lineLen() - raw.length;
} }
export const CaseMapping = { export const CaseMapping = {
@ -572,13 +485,13 @@ export const CaseMapping = {
let ch = str[i]; let ch = str[i];
if ("A" <= ch && ch <= "Z") { if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase(); ch = ch.toLowerCase();
} else if (ch === "{") { } else if (ch == "{") {
ch = "["; ch = "[";
} else if (ch === "}") { } else if (ch == "}") {
ch = "]"; ch = "]";
} else if (ch === "\\") { } else if (ch == "\\") {
ch = "|"; ch = "|";
} else if (ch === "~") { } else if (ch == "~") {
ch = "^"; ch = "^";
} }
out += ch; out += ch;
@ -592,11 +505,11 @@ export const CaseMapping = {
let ch = str[i]; let ch = str[i];
if ("A" <= ch && ch <= "Z") { if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase(); ch = ch.toLowerCase();
} else if (ch === "{") { } else if (ch == "{") {
ch = "["; ch = "[";
} else if (ch === "}") { } else if (ch == "}") {
ch = "]"; ch = "]";
} else if (ch === "\\") { } else if (ch == "\\") {
ch = "|"; ch = "|";
} }
out += ch; out += ch;
@ -759,10 +672,11 @@ export function getMessageLabel(msg) {
} }
export function forEachChannelModeUpdate(msg, isupport, callback) { export function forEachChannelModeUpdate(msg, isupport, callback) {
let [a, b, c, d] = isupport.chanModes(); let chanmodes = isupport.chanModes();
let prefix = isupport.prefix(); let prefix = isupport.prefix();
let typeByMode = new Map(); let typeByMode = new Map();
let [a, b, c, d] = chanmodes.split(",");
Array.from(a).forEach((mode) => typeByMode.set(mode, "A")); Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
Array.from(b).forEach((mode) => typeByMode.set(mode, "B")); Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
Array.from(c).forEach((mode) => typeByMode.set(mode, "C")); Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
@ -881,17 +795,6 @@ export function parseURL(str) {
return { host, enttype, entity }; return { host, enttype, entity };
} }
export function formatURL({ host, enttype, entity } = {}) {
host = host || "";
entity = entity || "";
let s = "irc://" + host + "/" + encodeURIComponent(entity);
if (enttype) {
s += ",is" + enttype;
}
return s;
}
export class CapRegistry { export class CapRegistry {
available = new Map(); available = new Map();
enabled = new Set(); enabled = new Set();
@ -931,6 +834,7 @@ export class CapRegistry {
}); });
break; break;
case "ACK": case "ACK":
// TODO: handle `ACK -cap` to
args[0].split(" ").forEach((cap) => { args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase(); cap = cap.toLowerCase();
if (cap.startsWith("-")) { if (cap.startsWith("-")) {
@ -954,19 +858,3 @@ export class CapRegistry {
return { command: "CAP", params: ["REQ", l.join(" ")] }; return { command: "CAP", params: ["REQ", l.join(" ")] };
} }
} }
const maxSASLLength = 400;
export function generateAuthenticateMessages(payload) {
let encoded = base64.encode(payload);
// <= instead of < because we need to send a final empty response if the
// last chunk is exactly 400 bytes long
let msgs = [];
for (let i = 0; i <= encoded.length; i += maxSASLLength) {
let chunk = encoded.substring(i, i + maxSASLLength);
msgs.push({ command: "AUTHENTICATE", params: [chunk || "+"] });
}
return msgs;
}

View File

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

View File

@ -1,109 +0,0 @@
function formatQueryString(params) {
let l = [];
for (let k in params) {
l.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
}
return l.join("&");
}
export async function fetchServerMetadata(url) {
// TODO: handle path in config.oauth2.url
let resp;
try {
resp = await fetch(url + "/.well-known/oauth-authorization-server");
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
} catch (err) {
console.warn("OAuth 2.0 server doesn't support Authorization Server Metadata (retrying with OpenID Connect Discovery): ", err);
resp = await fetch(url + "/.well-known/openid-configuration");
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
}
let data = await resp.json();
if (!data.issuer) {
throw new Error("Missing issuer in response");
}
if (!data.authorization_endpoint) {
throw new Error("Missing authorization_endpoint in response");
}
if (!data.token_endpoint) {
throw new Error("Missing authorization_endpoint in response");
}
if (!data.response_types_supported.includes("code")) {
throw new Error("Server doesn't support authorization code response type");
}
return data;
}
export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope }) {
// TODO: move fragment to query string in redirect_uri
// TODO: use the state param to prevent cross-site request
// forgery
let params = {
"response_type": "code",
"client_id": clientId,
"redirect_uri": redirectUri,
};
if (scope) {
params.scope = scope;
}
window.location.assign(serverMetadata.authorization_endpoint + "?" + formatQueryString(params));
}
function buildPostHeaders(clientId, clientSecret) {
let headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
};
if (clientSecret) {
headers["Authorization"] = "Basic " + btoa(encodeURIComponent(clientId) + ":" + encodeURIComponent(clientSecret));
}
return headers;
}
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
let data = {
"grant_type": "authorization_code",
code,
"redirect_uri": redirectUri,
};
if (!clientSecret) {
data["client_id"] = clientId;
}
let resp = await fetch(serverMetadata.token_endpoint, {
method: "POST",
headers: buildPostHeaders(clientId, clientSecret),
body: formatQueryString(data),
});
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
data = await resp.json();
if (data.error) {
throw new Error("Authentication failed: " + (data.error_description || data.error));
}
return data;
}
export async function introspectToken({ serverMetadata, token, clientId, clientSecret }) {
let resp = await fetch(serverMetadata.introspection_endpoint, {
method: "POST",
headers: buildPostHeaders(clientId, clientSecret),
body: formatQueryString({ token }),
});
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
let data = await resp.json();
if (!data.active) {
throw new Error("Expired token");
}
return data;
}

17020
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

192
state.js
View File

@ -1,6 +1,5 @@
import * as irc from "./lib/irc.js"; import * as irc from "./lib/irc.js";
import Client from "./lib/client.js"; import Client from "./lib/client.js";
import { createContext } from "./lib/index.js";
export const SERVER_BUFFER = "*"; export const SERVER_BUFFER = "*";
@ -35,22 +34,22 @@ export const ReceiptType = {
READ: "read", READ: "read",
}; };
export const BufferEventsDisplayMode = { export function getNickURL(nick) {
FOLD: "fold", return "irc:///" + encodeURIComponent(nick) + ",isuser";
EXPAND: "expand", }
HIDE: "hide",
};
export const SettingsContext = createContext("settings"); export function getChannelURL(channel) {
return "irc:///" + encodeURIComponent(channel);
}
export function getBufferURL(buf) { export function getBufferURL(buf) {
switch (buf.type) { switch (buf.type) {
case BufferType.SERVER: case BufferType.SERVER:
return irc.formatURL(); return "irc:///";
case BufferType.CHANNEL: case BufferType.CHANNEL:
return irc.formatURL({ entity: buf.name }); return getChannelURL(buf.name);
case BufferType.NICK: case BufferType.NICK:
return irc.formatURL({ entity: buf.name, enttype: "user" }); return getNickURL(buf.name);
} }
throw new Error("Unknown buffer type: " + buf.type); throw new Error("Unknown buffer type: " + buf.type);
} }
@ -86,42 +85,6 @@ export function getServerName(server, bouncerNetwork) {
} }
} }
export function receiptFromMessage(msg) {
// At this point all messages are supposed to have a time tag.
// App.addMessage ensures this is the case even if the server doesn't
// support server-time.
if (!msg.tags.time) {
throw new Error("Missing time message tag");
}
return { time: msg.tags.time };
}
export function isReceiptBefore(a, b) {
if (!b) {
return false;
}
if (!a) {
return true;
}
if (!a.time || !b.time) {
throw new Error("Missing receipt time");
}
return a.time <= b.time;
}
export function isMessageBeforeReceipt(msg, receipt) {
if (!receipt) {
return false;
}
if (!msg.tags.time) {
throw new Error("Missing time message tag");
}
if (!receipt.time) {
throw new Error("Missing receipt time");
}
return msg.tags.time <= receipt.time;
}
function updateState(state, updater) { function updateState(state, updater) {
let updated; let updated;
if (typeof updater === "function") { if (typeof updater === "function") {
@ -136,59 +99,22 @@ function updateState(state, updater) {
} }
function isServerBuffer(buf) { function isServerBuffer(buf) {
return buf.type === BufferType.SERVER; return buf.type == BufferType.SERVER;
}
function isChannelBuffer(buf) {
return buf.type === BufferType.CHANNEL;
}
function trimStartCharacter(s, c) {
let i = 0;
for (; i < s.length; ++i) {
if (s[i] !== c) {
break;
}
}
return s.substring(i);
}
function getBouncerNetworkNameFromBuffer(state, buffer) {
let server = state.servers.get(buffer.server);
let network = state.bouncerNetworks.get(server.bouncerNetID);
if (!network) {
return null;
}
return getServerName(server, network);
} }
/* Returns 1 if a should appear after b, -1 if a should appear before b, or /* Returns 1 if a should appear after b, -1 if a should appear before b, or
* 0 otherwise. */ * 0 otherwise. */
function compareBuffers(state, a, b) { function compareBuffers(a, b) {
if (a.server !== b.server) { if (a.server != b.server) {
let aServerName = getBouncerNetworkNameFromBuffer(state, a);
let bServerName = getBouncerNetworkNameFromBuffer(state, b);
if (aServerName && bServerName && aServerName !== bServerName) {
return aServerName.localeCompare(bServerName);
}
return a.server > b.server ? 1 : -1; return a.server > b.server ? 1 : -1;
} }
if (isServerBuffer(a) !== isServerBuffer(b)) { if (isServerBuffer(a) != isServerBuffer(b)) {
return isServerBuffer(b) ? 1 : -1; return isServerBuffer(b) ? 1 : -1;
} }
if (a.name != b.name) {
if (isChannelBuffer(a) && isChannelBuffer(b)) { return a.name > b.name ? 1 : -1;
const strippedA = trimStartCharacter(a.name, a.name[0]);
const strippedB = trimStartCharacter(b.name, b.name[0]);
const cmp = strippedA.localeCompare(strippedB);
if (cmp !== 0) {
return cmp;
} }
// if they are the same when stripped, fallthough to default logic return 0;
}
return a.name.localeCompare(b.name);
} }
function updateMembership(membership, letter, add, client) { function updateMembership(membership, letter, add, client) {
@ -215,7 +141,7 @@ function updateMembership(membership, letter, add, client) {
/* Insert a message in an immutable list of sorted messages. */ /* Insert a message in an immutable list of sorted messages. */
function insertMessage(list, msg) { function insertMessage(list, msg) {
if (list.length === 0) { if (list.length == 0) {
return [msg]; return [msg];
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) { } else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
return list.concat(msg); return list.concat(msg);
@ -246,11 +172,6 @@ export const State = {
servers: new Map(), servers: new Map(),
buffers: new Map(), buffers: new Map(),
activeBuffer: null, activeBuffer: null,
bouncerNetworks: new Map(),
settings: {
secondsInTimestamps: true,
bufferEvents: BufferEventsDisplayMode.FOLD,
},
}; };
}, },
updateServer(state, id, updater) { updateServer(state, id, updater) {
@ -355,7 +276,7 @@ export const State = {
let id = lastBufferID; let id = lastBufferID;
let type; let type;
if (name === SERVER_BUFFER) { if (name == SERVER_BUFFER) {
type = BufferType.SERVER; type = BufferType.SERVER;
} else if (client.isChannel(name)) { } else if (client.isChannel(name)) {
type = BufferType.CHANNEL; type = BufferType.CHANNEL;
@ -372,30 +293,15 @@ export const State = {
serverInfo: null, // if server serverInfo: null, // if server
joined: false, // if channel joined: false, // if channel
topic: null, // if channel topic: null, // if channel
hasInitialWho: false, // if channel
members: new irc.CaseMapMap(null, client.cm), // if channel members: new irc.CaseMapMap(null, client.cm), // if channel
messages: [], messages: [],
redacted: new Set(),
unread: Unread.NONE, unread: Unread.NONE,
prevReadReceipt: null, prevReadReceipt: null,
}); });
bufferList = bufferList.sort((a, b) => compareBuffers(state, a, b)); bufferList = bufferList.sort(compareBuffers);
let buffers = new Map(bufferList.map((buf) => [buf.id, buf])); let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
return [id, { buffers }]; return [id, { buffers }];
}, },
storeBouncerNetwork(state, id, attrs) {
let bouncerNetworks = new Map(state.bouncerNetworks);
bouncerNetworks.set(id, {
...bouncerNetworks.get(id),
...attrs,
});
return { bouncerNetworks };
},
deleteBouncerNetwork(state, id) {
let bouncerNetworks = new Map(state.bouncerNetworks);
bouncerNetworks.delete(id);
return { bouncerNetworks };
},
handleMessage(state, msg, serverID, client) { handleMessage(state, msg, serverID, client) {
function updateServer(updater) { function updateServer(updater) {
return State.updateServer(state, serverID, updater); return State.updateServer(state, serverID, updater);
@ -432,7 +338,7 @@ export const State = {
case irc.RPL_ISUPPORT: case irc.RPL_ISUPPORT:
buffers = new Map(state.buffers); buffers = new Map(state.buffers);
state.buffers.forEach((buf) => { state.buffers.forEach((buf) => {
if (buf.server !== serverID) { if (buf.server != serverID) {
return; return;
} }
let members = new irc.CaseMapMap(buf.members, client.cm); let members = new irc.CaseMapMap(buf.members, client.cm);
@ -490,41 +396,34 @@ export const State = {
}); });
return { members }; return { members };
}); });
case irc.RPL_ENDOFWHO: break;
target = msg.params[1]; case irc.RPL_WHOREPLY:
if (msg.list.length === 0 && !client.isChannel(target) && target.indexOf("*") < 0) { case irc.RPL_WHOSPCRPL:
// Not a channel nor a mask, likely a nick who = client.parseWhoReply(msg);
return updateUser(target, (user) => {
return { offline: true };
});
} else {
return updateServer((server) => {
let users = new irc.CaseMapMap(server.users);
for (let reply of msg.list) {
let who = client.parseWhoReply(reply);
if (who.flags !== undefined) { 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; who.operator = who.flags.indexOf("*") >= 0;
let botFlag = client.isupport.bot();
if (botFlag) {
who.bot = who.flags.indexOf(botFlag) >= 0;
}
delete who.flags; delete who.flags;
} }
who.offline = false; who.offline = false;
users.set(who.nick, who); return updateUser(who.nick, who);
} case irc.RPL_ENDOFWHO:
return { users }; target = msg.params[1];
if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
// Not a channel nor a mask, likely a nick
return updateUser(target, (user) => {
return { offline: true };
}); });
} }
break;
case "JOIN": case "JOIN":
channel = msg.params[0]; channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) { if (client.isMyNick(msg.prefix.name)) {
let [_id, update] = State.createBuffer(state, channel, serverID, client); let [id, update] = State.createBuffer(state, channel, serverID, client);
state = { ...state, ...update }; state = { ...state, ...update };
} }
@ -582,7 +481,7 @@ export const State = {
case "QUIT": case "QUIT":
buffers = new Map(state.buffers); buffers = new Map(state.buffers);
state.buffers.forEach((buf) => { state.buffers.forEach((buf) => {
if (buf.server !== serverID) { if (buf.server != serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
@ -608,7 +507,7 @@ export const State = {
buffers = new Map(state.buffers); buffers = new Map(state.buffers);
state.buffers.forEach((buf) => { state.buffers.forEach((buf) => {
if (buf.server !== serverID) { if (buf.server != serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
@ -649,7 +548,7 @@ export const State = {
return updateUser(msg.prefix.name, { account }); return updateUser(msg.prefix.name, { account });
case "AWAY": case "AWAY":
let awayMessage = msg.params[0]; let awayMessage = msg.params[0];
return updateUser(msg.prefix.name, { away: Boolean(awayMessage) }); return updateUser(msg.prefix.name, { away: !!awayMessage });
case "TOPIC": case "TOPIC":
channel = msg.params[0]; channel = msg.params[0];
topic = msg.params[1]; topic = msg.params[1];
@ -680,21 +579,22 @@ export const State = {
return { members }; return { members };
}); });
case "REDACT":
target = msg.params[0];
if (client.isMyNick(target)) {
target = msg.prefix.name;
}
return updateBuffer(target, (buf) => {
return { redacted: new Set(buf.redacted).add(msg.params[1]) };
});
case irc.RPL_MONONLINE: case irc.RPL_MONONLINE:
targets = msg.params[1].split(",");
for (let target of targets) {
let prefix = irc.parsePrefix(target);
let update = updateUser(prefix.name, { offline: false });
state = { ...state, ...update };
}
return state;
case irc.RPL_MONOFFLINE: case irc.RPL_MONOFFLINE:
targets = msg.params[1].split(","); targets = msg.params[1].split(",");
for (let target of targets) { for (let target of targets) {
let prefix = irc.parsePrefix(target); let prefix = irc.parsePrefix(target);
let update = updateUser(prefix.name, { offline: msg.command === irc.RPL_MONOFFLINE }); let update = updateUser(prefix.name, { offline: true });
state = { ...state, ...update }; state = { ...state, ...update };
} }

View File

@ -1,5 +1,3 @@
import { ReceiptType, Unread } from "./state.js";
const PREFIX = "gamja_"; const PREFIX = "gamja_";
class Item { class Item {
@ -26,18 +24,18 @@ class Item {
export const autoconnect = new Item("autoconnect"); export const autoconnect = new Item("autoconnect");
export const naggedProtocolHandler = new Item("naggedProtocolHandler"); export const naggedProtocolHandler = new Item("naggedProtocolHandler");
export const settings = new Item("settings");
function debounce(f, delay) { const rawReceipts = new Item("receipts");
let timeout = null;
return (...args) => { export const receipts = {
clearTimeout(timeout); load() {
timeout = setTimeout(() => { let v = rawReceipts.load();
timeout = null; return new Map(Object.entries(v || {}));
f(...args); },
}, delay); put(m) {
rawReceipts.put(Object.fromEntries(m));
},
}; };
}
export class Buffer { export class Buffer {
raw = new Item("buffers"); raw = new Item("buffers");
@ -46,22 +44,14 @@ export class Buffer {
constructor() { constructor() {
let obj = this.raw.load(); let obj = this.raw.load();
this.m = new Map(Object.entries(obj || {})); this.m = new Map(Object.entries(obj || {}));
let saveImmediately = this.save.bind(this);
this.save = debounce(saveImmediately, 500);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
saveImmediately();
}
});
} }
key(buf) { key(buf) {
// TODO: use case-mapping here somehow
return JSON.stringify({ return JSON.stringify({
name: buf.name.toLowerCase(), name: buf.name,
server: { server: {
url: buf.server.url,
nick: buf.server.nick,
bouncerNetwork: buf.server.bouncerNetwork, bouncerNetwork: buf.server.bouncerNetwork,
}, },
}); });
@ -82,53 +72,22 @@ export class Buffer {
put(buf) { put(buf) {
let key = this.key(buf); let key = this.key(buf);
let updated = !this.m.has(key); let prev = this.m.get(key);
let prev = this.m.get(key) || {}; if (prev && prev.unread === buf.unread) {
return;
let unread = prev.unread || Unread.NONE;
if (buf.unread !== undefined && buf.unread !== prev.unread) {
unread = buf.unread;
updated = true;
}
let receipts = { ...prev.receipts };
if (buf.receipts) {
Object.keys(buf.receipts).forEach((k) => {
// Use a not-equals comparison here so that no-op receipt
// changes are correctly handled
if (!receipts[k] || receipts[k].time < buf.receipts[k].time) {
receipts[k] = buf.receipts[k];
updated = true;
}
});
if (receipts[ReceiptType.DELIVERED] < receipts[ReceiptType.READ]) {
receipts[ReceiptType.DELIVERED] = receipts[ReceiptType.READ];
updated = true;
}
}
let closed = prev.closed || false;
if (buf.closed !== undefined && buf.closed !== prev.closed) {
closed = buf.closed;
updated = true;
}
if (!updated) {
return false;
} }
this.m.set(this.key(buf), { this.m.set(this.key(buf), {
name: buf.name, name: buf.name,
unread, unread: buf.unread,
receipts,
closed,
server: { server: {
url: buf.server.url,
nick: buf.server.nick,
bouncerNetwork: buf.server.bouncerNetwork, bouncerNetwork: buf.server.bouncerNetwork,
}, },
}); });
this.save(); this.save();
return true;
} }
delete(buf) { delete(buf) {
@ -137,25 +96,19 @@ export class Buffer {
} }
list(server) { list(server) {
// Some gamja versions would store the same buffer multiple times
let names = new Set();
let buffers = []; let buffers = [];
for (const buf of this.m.values()) { for (const buf of this.m.values()) {
if (buf.server.bouncerNetwork !== server.bouncerNetwork) { if (buf.server.url !== server.url || buf.server.nick !== server.nick || buf.server.bouncerNetwork !== server.bouncerNetwork) {
continue;
}
if (names.has(buf.name)) {
continue; continue;
} }
buffers.push(buf); buffers.push(buf);
names.add(buf.name);
} }
return buffers; return buffers;
} }
clear(server) { clear(server) {
if (server) { if (server) {
for (const buf of this.list(server)) { for (const buf of this.m.values()) {
this.m.delete(this.key(buf)); this.m.delete(this.key(buf));
} }
} else { } else {

View File

@ -154,8 +154,9 @@ button.danger:hover {
padding: 2px 10px; padding: 2px 10px;
box-sizing: border-box; box-sizing: border-box;
} }
#buffer-list li.error a { #buffer-list li.active a {
color: red; color: white;
background-color: var(--gray);
} }
#buffer-list li.unread-message a { #buffer-list li.unread-message a {
color: #b37400; color: #b37400;
@ -163,10 +164,6 @@ button.danger:hover {
#buffer-list li.unread-highlight a { #buffer-list li.unread-highlight a {
color: #22009b; color: #22009b;
} }
#buffer-list li.active a {
color: white;
background-color: var(--gray);
}
#buffer-list li:not(.type-server) a { #buffer-list li:not(.type-server) a {
padding-left: 20px; padding-left: 20px;
} }
@ -189,7 +186,7 @@ button.danger:hover {
grid-column: 2; grid-column: 2;
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: auto auto;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
@ -205,9 +202,6 @@ button.danger:hover {
padding: 5px 10px; padding: 5px 10px;
grid-row: 2; grid-row: 2;
grid-column: 1; grid-column: 1;
max-height: 20vh;
overflow-y: auto;
word-break: break-word;
} }
#buffer-header .actions { #buffer-header .actions {
@ -302,7 +296,7 @@ button.danger:hover {
.membership.admin { .membership.admin {
color: blue; color: blue;
} }
.membership.operator { .membership.op {
color: var(--green); color: var(--green);
} }
.membership.halfop { .membership.halfop {
@ -352,26 +346,17 @@ form input[type="text"],
form input[type="username"], form input[type="username"],
form input[type="password"], form input[type="password"],
form input[type="url"], form input[type="url"],
form input[type="email"], form input[type="email"] {
form input[type="search"] {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
font-family: inherit;
font-size: inherit;
} }
a { a {
color: var(--green); color: var(--green);
} }
#buffer-list li a, a.timestamp, a.nick { #buffer-list li a, a.timestamp, a.nick {
text-decoration: none;
}
#buffer-list li a,
a.nick {
color: var(--main-color);
}
a.timestamp {
color: var(--gray); color: var(--gray);
text-decoration: none;
} }
#buffer-list li a:hover, #buffer-list li a:active, #buffer-list li a:hover, #buffer-list li a:active,
a.timestamp:hover, a.timestamp:active, a.timestamp:hover, a.timestamp:active,
@ -394,7 +379,6 @@ details summary[role="button"] {
} }
#buffer .logline { #buffer .logline {
white-space: pre-wrap; white-space: pre-wrap;
overflow: auto;
} }
#buffer .talk, #buffer .motd { #buffer .talk, #buffer .motd {
color: var(--main-color); color: var(--main-color);
@ -546,14 +530,6 @@ details summary[role="button"] {
overflow: auto; /* hack to clear floating elements */ overflow: auto; /* hack to clear floating elements */
} }
.dialog .protocol-handler {
display: flex;
flex-direction: row;
}
.dialog .protocol-handler .left {
flex-grow: 1;
}
kbd { kbd {
background-color: #f0f0f0; background-color: #f0f0f0;
border: 1px solid #bfbfbf; border: 1px solid #bfbfbf;
@ -569,44 +545,21 @@ kbd {
border-radius: 3px; border-radius: 3px;
} }
ul.switcher-list {
list-style-type: none;
margin: 0;
padding: 0;
margin-top: 10px;
}
ul.switcher-list li a {
display: inline-block;
width: 100%;
padding: 5px 10px;
margin: 4px 0;
box-sizing: border-box;
text-decoration: none;
color: inherit;
}
ul.switcher-list li a.selected {
background-color: rgba(0, 0, 0, 0.1);
}
ul.switcher-list .server {
float: right;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
scrollbar-color: var(--gray) transparent; scrollbar-color: var(--gray) transparent;
} }
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-list li.unread-message a { #buffer-list li.unread-message a {
color: var(--green); color: var(--green);
} }
#buffer-list li.unread-highlight a { #buffer-list li.unread-highlight a {
color: #0062cc; color: #0062cc;
} }
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-header .status-gone { #buffer-header .status-gone {
color: #fb885b; color: #fb885b;
@ -619,8 +572,7 @@ ul.switcher-list .server {
form input[type="username"], form input[type="username"],
form input[type="password"], form input[type="password"],
form input[type="url"], form input[type="url"],
form input[type="email"], form input[type="email"] {
form input[type="search"] {
color: #ffffff; color: #ffffff;
background: var(--sidebar-background); background: var(--sidebar-background);
border: 1px solid #495057; border: 1px solid #495057;
@ -630,12 +582,16 @@ ul.switcher-list .server {
form input[type="username"]:focus, form input[type="username"]:focus,
form input[type="password"]:focus, form input[type="password"]:focus,
form input[type="url"]:focus, form input[type="url"]:focus,
form input[type="email"]:focus, form input[type="email"]:focus {
form input[type="search"]:focus {
outline: 0; outline: 0;
border-color: #3897ff; border-color: #3897ff;
} }
#buffer-list li a,
a.nick {
color: var(--main-color);
}
#buffer { #buffer {
background: var(--main-background); background: var(--main-background);
} }
@ -705,10 +661,6 @@ ul.switcher-list .server {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
box-shadow: inset 0 -1px 0 var(--outline-color); box-shadow: inset 0 -1px 0 var(--outline-color);
} }
ul.switcher-list li a.selected {
background-color: rgba(255, 255, 255, 0.1);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {