Compare commits

..

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

37 changed files with 1721 additions and 3204 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 - 77c7956b-003e-44f7-bb5c-2944b2047654 # 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@sheeta.emersion.fr:/srv/http/gamja

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,7 +10,7 @@ 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.
@ -37,7 +37,7 @@ gamja can be configured via a [configuration file] and via [URL parameters].
## 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,8 +46,8 @@ 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 [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 [configure an HTTP server]: doc/setup.md

View File

@ -53,7 +53,6 @@ function markServerBufferUnread(app) {
} }
const join = { const join = {
name: "join",
usage: "<name> [password]", usage: "<name> [password]",
description: "Join a channel", description: "Join a channel",
execute: (app, args) => { execute: (app, args) => {
@ -70,7 +69,6 @@ const join = {
}; };
const kick = { const kick = {
name: "kick",
usage: "<nick> [comment]", usage: "<nick> [comment]",
description: "Remove a user from the channel", description: "Remove a user from the channel",
execute: (app, args) => { execute: (app, args) => {
@ -85,11 +83,10 @@ const kick = {
}; };
const ban = { const ban = {
name: "ban",
usage: "[nick]", usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list", description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => { execute: (app, args) => {
if (args.length === 0) { if (args.length == 0) {
let activeChannel = getActiveChannel(app); let activeChannel = getActiveChannel(app);
getActiveClient(app).send({ getActiveClient(app).send({
command: "MODE", command: "MODE",
@ -114,22 +111,20 @@ 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": ban,
{ "buffer": {
name: "buffer",
usage: "<name>", usage: "<name>",
description: "Switch to a buffer", description: "Switch to a buffer",
execute: (app, args) => { execute: (app, args) => {
@ -143,45 +138,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 +179,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 +195,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 +203,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 +213,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 +225,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 +233,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 +242,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 +250,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 +259,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 +277,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 +293,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 +307,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 +326,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 +345,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 +361,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,30 +373,31 @@ 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>", usage: "<mask>",
description: "Retrieve a list of users", description: "Retrieve a list of users",
execute: (app, args) => { execute: (app, args) => {
@ -434,8 +405,7 @@ const commands = [
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 +417,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 +428,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 +436,4 @@ const commands = [
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
]; };
export default new Map(commands.map((cmd) => [cmd.name, cmd]));

View File

@ -55,7 +55,7 @@ function isProduction() {
// NODE_ENV is set by the Parcel build system // NODE_ENV is set by the Parcel build system
try { try {
return process.env.NODE_ENV === "production"; return process.env.NODE_ENV === "production";
} catch (_err) { } catch (err) {
return false; return false;
} }
} }
@ -94,7 +94,7 @@ function splitHostPort(str) {
function fillConnectParams(params) { function fillConnectParams(params) {
let host = window.location.host || "localhost:8080"; let host = window.location.host || "localhost:8080";
let proto = "wss:"; let proto = "wss:";
if (window.location.protocol !== "https:") { if (window.location.protocol != "https:") {
proto = "ws:"; proto = "ws:";
} }
let path = window.location.pathname || "/"; let path = window.location.pathname || "/";
@ -323,8 +323,6 @@ export default class App extends Component {
} }
if (queryParams.debug === "1") { if (queryParams.debug === "1") {
this.debug = true; this.debug = true;
} else if (queryParams.debug === "0") {
this.debug = false;
} }
if (window.location.hash) { if (window.location.hash) {
@ -385,7 +383,7 @@ export default class App extends Component {
} }
} }
this.setState({ loading: false, connectParams }); this.setState({ loading: false, connectParams: connectParams });
if (connectParams.autoconnect) { if (connectParams.autoconnect) {
this.setState({ connectForm: false }); this.setState({ connectForm: false });
@ -439,9 +437,6 @@ export default class App extends Component {
clientSecret: this.config.oauth2.client_secret, clientSecret: this.config.oauth2.client_secret,
}); });
username = data.username; username = data.username;
if (!username) {
console.warn("Username missing from OAuth 2.0 token introspection response");
}
} catch (err) { } catch (err) {
console.warn("Failed to introspect OAuth 2.0 token:", err); console.warn("Failed to introspect OAuth 2.0 token:", err);
} }
@ -498,9 +493,7 @@ export default class App extends Component {
let stored = this.bufferStore.get({ name, server: client.params }); let stored = this.bufferStore.get({ name, server: client.params });
if (client.caps.enabled.has("draft/chathistory") && stored) { if (client.caps.enabled.has("draft/chathistory") && stored) {
this.setBufferState({ server: serverID, name }, { unread: stored.unread }, () => { this.setBufferState({ server: serverID, name }, { unread: stored.unread });
this.updateDocumentTitle();
});
} }
this.bufferStore.put({ this.bufferStore.put({
@ -517,7 +510,7 @@ export default class App extends Component {
this.setState((state) => { this.setState((state) => {
let updated; let updated;
[id, updated] = State.createBuffer(state, name, serverID, client); [id, updated] = State.createBuffer(state, name, serverID, client);
isNew = Boolean(updated); isNew = !!updated;
return updated; return updated;
}); });
if (isNew) { if (isNew) {
@ -548,9 +541,13 @@ export default class App extends Component {
let client = this.clients.get(buf.server); let client = this.clients.get(buf.server);
let stored = this.bufferStore.get({ name: buf.name, server: client.params }); let stored = this.bufferStore.get({ name: buf.name, server: client.params });
let prevReadReceipt = getReceipt(stored, ReceiptType.READ); let prevReadReceipt = getReceipt(stored, ReceiptType.READ);
let update = State.updateBuffer(state, buf.id, { prevReadReceipt }); // TODO: only mark as read if user scrolled at the bottom
let update = State.updateBuffer(state, buf.id, {
unread: Unread.NONE,
prevReadReceipt,
});
return { activeBuffer: buf.id, ...update }; return { ...update, activeBuffer: buf.id };
}, () => { }, () => {
if (!buf) { if (!buf) {
return; return;
@ -560,35 +557,6 @@ export default class App extends Component {
this.buffer.current.focus(); this.buffer.current.focus();
} }
let server = this.state.servers.get(buf.server);
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
this.whoChannelBuffer(buf.name, buf.server);
}
this.updateDocumentTitle();
});
// TODO: only mark as read if user scrolled at the bottom
this.markBufferAsRead(id);
}
markBufferAsRead(id) {
let buf;
this.setState((state) => {
buf = State.getBuffer(state, id);
if (!buf) {
return;
}
return State.updateBuffer(state, buf.id, { unread: Unread.NONE });
}, () => {
if (!buf) {
return;
}
let client = this.clients.get(buf.server); let client = this.clients.get(buf.server);
for (let notif of this.messageNotifications) { for (let notif of this.messageNotifications) {
@ -610,46 +578,23 @@ export default class App extends Component {
} }
} }
this.updateDocumentTitle(); let server = this.state.servers.get(buf.server);
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
this.whoChannelBuffer(buf.name, buf.server);
}
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
document.title = this.baseTitle;
}
}); });
} }
updateDocumentTitle() {
let buf = State.getBuffer(this.state, this.state.activeBuffer);
let server;
if (buf) {
server = this.state.servers.get(buf.server);
}
let bouncerNetwork;
if (server.bouncerNetID) {
bouncerNetwork = this.state.bouncerNetworks.get(server.bouncerNetID);
}
let numUnread = 0;
for (let buffer of this.state.buffers.values()) {
if (Unread.compare(buffer.unread, Unread.HIGHLIGHT) >= 0) {
numUnread++;
}
}
let parts = [];
if (buf && buf.type !== BufferType.SERVER) {
parts.push(buf.name);
}
if (bouncerNetwork) {
parts.push(getServerName(server, bouncerNetwork));
}
parts.push(this.baseTitle);
let title = "";
if (numUnread > 0) {
title = `(${numUnread}) `;
}
title += parts.join(" · ");
document.title = title;
}
prepareChatMessage(serverID, msg) { prepareChatMessage(serverID, msg) {
// Treat server-wide broadcasts as highlights. They're sent by server // Treat server-wide broadcasts as highlights. They're sent by server
// operators and can contain important information. // operators and can contain important information.
@ -689,7 +634,7 @@ export default class App extends Component {
} }
let msgUnread = Unread.NONE; let msgUnread = Unread.NONE;
if ((msg.command === "PRIVMSG" || msg.command === "NOTICE") && !isRead) { if ((msg.command == "PRIVMSG" || msg.command == "NOTICE") && !isRead) {
let target = msg.params[0]; let target = msg.params[0];
let text = msg.params[1]; let text = msg.params[1];
@ -704,7 +649,7 @@ export default class App extends Component {
msgUnread = Unread.MESSAGE; msgUnread = Unread.MESSAGE;
} }
if (msgUnread === Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) { if (msgUnread == Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) {
let title = "New " + kind + " from " + msg.prefix.name; let title = "New " + kind + " from " + msg.prefix.name;
if (client.isChannel(bufName)) { if (client.isChannel(bufName)) {
title += " in " + bufName; title += " in " + bufName;
@ -762,7 +707,7 @@ export default class App extends Component {
// Open a new buffer if the message doesn't come from me or is a // Open a new buffer if the message doesn't come from me or is a
// self-message // self-message
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command !== "PART" && msg.command !== "QUIT" && msg.command !== irc.RPL_MONONLINE && msg.command !== irc.RPL_MONOFFLINE)) { if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command != "PART" && msg.comand != "QUIT" && msg.command != irc.RPL_MONONLINE && msg.command != irc.RPL_MONOFFLINE)) {
this.createBuffer(serverID, bufName); this.createBuffer(serverID, bufName);
} }
@ -774,7 +719,7 @@ export default class App extends Component {
let prevReadReceipt = buf.prevReadReceipt; let prevReadReceipt = buf.prevReadReceipt;
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) }; let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer !== buf.id || !document.hasFocus()) { if (this.state.activeBuffer !== buf.id) {
unread = Unread.union(unread, msgUnread); unread = Unread.union(unread, msgUnread);
} else { } else {
receipts[ReceiptType.READ] = receiptFromMessage(msg); receipts[ReceiptType.READ] = receiptFromMessage(msg);
@ -795,10 +740,6 @@ export default class App extends Component {
this.sendReadReceipt(client, stored); this.sendReadReceipt(client, stored);
} }
return { unread, prevReadReceipt }; return { unread, prevReadReceipt };
}, () => {
if (msgUnread === Unread.HIGHLIGHT) {
this.updateDocumentTitle();
}
}); });
} }
@ -908,12 +849,6 @@ export default class App extends Component {
let client = this.clients.get(serverID); let client = this.clients.get(serverID);
let chatHistoryBatch = irc.findBatchByType(msg, "chathistory"); let chatHistoryBatch = irc.findBatchByType(msg, "chathistory");
// Reply triggered by some command sent by us, not worth displaying to
// the user
if (msg.internal) {
return [];
}
let target, channel, affectedBuffers; let target, channel, affectedBuffers;
switch (msg.command) { switch (msg.command) {
case "MODE": case "MODE":
@ -929,7 +864,7 @@ export default class App extends Component {
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) { if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
target = SERVER_BUFFER; target = SERVER_BUFFER;
} else { } else {
let context = msg.tags["+draft/channel-context"]; let context = msg.tags['+draft/channel-context'];
if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) { if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) {
target = context; target = context;
} else { } else {
@ -938,14 +873,6 @@ export default class App extends Component {
} }
} }
let allowedPrefixes = client.isupport.statusMsg();
if (allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
if (client.isChannel(parts.name)) {
target = parts.name;
}
}
// Don't open a new buffer if this is just a NOTICE or a garbage // Don't open a new buffer if this is just a NOTICE or a garbage
// CTCP message // CTCP message
let openNewBuffer = true; let openNewBuffer = true;
@ -961,6 +888,13 @@ export default class App extends Component {
target = SERVER_BUFFER; target = SERVER_BUFFER;
} }
let allowedPrefixes = client.isupport.statusMsg();
if (allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
if (client.isChannel(parts.name)) {
target = parts.name;
}
}
return [target]; return [target];
case "JOIN": case "JOIN":
channel = msg.params[0]; channel = msg.params[0];
@ -980,7 +914,7 @@ export default class App extends Component {
affectedBuffers.push(chatHistoryBatch.params[0]); affectedBuffers.push(chatHistoryBatch.params[0]);
} else { } else {
this.state.buffers.forEach((buf) => { this.state.buffers.forEach((buf) => {
if (buf.server !== serverID) { if (buf.server != serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
@ -998,7 +932,7 @@ export default class App extends Component {
affectedBuffers.push(chatHistoryBatch.params[0]); affectedBuffers.push(chatHistoryBatch.params[0]);
} else { } else {
this.state.buffers.forEach((buf) => { this.state.buffers.forEach((buf) => {
if (buf.server !== serverID) { if (buf.server != serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
@ -1075,7 +1009,6 @@ export default class App extends Component {
case "ACK": case "ACK":
case "BOUNCER": case "BOUNCER":
case "MARKREAD": case "MARKREAD":
case "REDACT":
// Ignore these // Ignore these
return []; return [];
default: default:
@ -1145,14 +1078,13 @@ export default class App extends Component {
this.openURL(this.autoOpenURL); this.openURL(this.autoOpenURL);
this.autoOpenURL = null; this.autoOpenURL = null;
} }
break;
case "JOIN": case "JOIN":
channel = msg.params[0]; channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) { if (client.isMyNick(msg.prefix.name)) {
this.syncBufferUnread(serverID, channel); this.syncBufferUnread(serverID, channel);
} }
if (channel === this.switchToChannel) { if (channel == this.switchToChannel) {
this.switchBuffer({ server: serverID, name: channel }); this.switchBuffer({ server: serverID, name: channel });
this.switchToChannel = null; this.switchToChannel = null;
} }
@ -1278,11 +1210,10 @@ export default class App extends Component {
closed, closed,
receipts: { [ReceiptType.READ]: readReceipt }, receipts: { [ReceiptType.READ]: readReceipt },
}); });
this.updateDocumentTitle();
}); });
break; break;
default: default:
if (irc.isError(msg.command) && msg.command !== irc.ERR_NOMOTD) { if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
let description = msg.params[msg.params.length - 1]; let description = msg.params[msg.params.length - 1];
this.showError(description); this.showError(description);
} }
@ -1530,7 +1461,7 @@ export default class App extends Component {
servers.delete(buf.server); servers.delete(buf.server);
let connectForm = state.connectForm; let connectForm = state.connectForm;
if (servers.size === 0) { if (servers.size == 0) {
connectForm = true; connectForm = true;
} }
@ -1586,7 +1517,7 @@ export default class App extends Component {
let name = parts[0].toLowerCase().slice(1); let name = parts[0].toLowerCase().slice(1);
let args = parts.slice(1); let args = parts.slice(1);
let cmd = commands.get(name); let cmd = commands[name];
if (!cmd) { if (!cmd) {
this.showError(`Unknown command "${name}" (run "/help" to get a command list)`); this.showError(`Unknown command "${name}" (run "/help" to get a command list)`);
return; return;
@ -1601,7 +1532,7 @@ export default class App extends Component {
} }
privmsg(target, text) { privmsg(target, text) {
if (target === SERVER_BUFFER) { if (target == SERVER_BUFFER) {
this.showError("Cannot send message in server buffer"); this.showError("Cannot send message in server buffer");
return; return;
} }
@ -1718,8 +1649,8 @@ export default class App extends Component {
} }
if (prefix.startsWith("/")) { if (prefix.startsWith("/")) {
let repl = fromList([...commands.keys()], prefix.slice(1)); let repl = fromList(Object.keys(commands), prefix.slice(1));
return repl.map((cmd) => "/" + cmd); return repl.map(cmd => "/" + cmd);
} }
// TODO: consider using the CHANTYPES ISUPPORT token here // TODO: consider using the CHANTYPES ISUPPORT token here
@ -1746,7 +1677,7 @@ export default class App extends Component {
async handleBufferScrollTop() { async handleBufferScrollTop() {
let buf = this.state.buffers.get(this.state.activeBuffer); let buf = this.state.buffers.get(this.state.activeBuffer);
if (!buf || buf.type === BufferType.SERVER) { if (!buf || buf.type == BufferType.SERVER) {
return; return;
} }
@ -1925,7 +1856,7 @@ export default class App extends Component {
this.dismissDialog(); this.dismissDialog();
if (this.state.dialogData && this.state.dialogData.id) { if (this.state.dialogData && this.state.dialogData.id) {
if (Object.keys(attrs).length === 0) { if (Object.keys(attrs).length == 0) {
return; return;
} }
@ -1963,7 +1894,7 @@ export default class App extends Component {
handleOpenSettingsClick() { handleOpenSettingsClick() {
let showProtocolHandler = false; let showProtocolHandler = false;
for (let [_id, client] of this.clients) { for (let [id, client] of this.clients) {
if (client.caps.enabled.has("soju.im/bouncer-networks")) { if (client.caps.enabled.has("soju.im/bouncer-networks")) {
showProtocolHandler = true; showProtocolHandler = true;
break; break;
@ -1991,11 +1922,6 @@ export default class App extends Component {
} }
handleWindowFocus() { handleWindowFocus() {
if (this.state.activeBuffer) {
// TODO: only do this if scrolled at the bottom
this.markBufferAsRead(this.state.activeBuffer);
}
// When the user focuses gamja, send a PING to make sure we detect any // When the user focuses gamja, send a PING to make sure we detect any
// network errors ASAP // network errors ASAP
@ -2006,11 +1932,9 @@ export default class App extends Component {
this.lastFocusPingDate = now; this.lastFocusPingDate = now;
for (let client of this.clients.values()) { for (let client of this.clients.values()) {
if (client.status === Client.Status.REGISTERED) {
client.send({ command: "PING", params: ["gamja"] }); client.send({ command: "PING", params: ["gamja"] });
} }
} }
}
componentDidMount() { componentDidMount() {
this.baseTitle = document.title; this.baseTitle = document.title;
@ -2043,11 +1967,6 @@ export default class App extends Component {
} }
} }
let activeClient = null;
if (activeBuffer) {
activeClient = this.clients.get(activeBuffer.server);
}
if (this.state.connectForm) { if (this.state.connectForm) {
let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED; let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING; let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
@ -2067,7 +1986,7 @@ export default class App extends Component {
let bufferHeader = null; let bufferHeader = null;
if (activeBuffer) { if (activeBuffer) {
let activeUser = null; let activeUser = null;
if (activeBuffer.type === BufferType.NICK) { if (activeBuffer.type == BufferType.NICK) {
activeUser = activeServer.users.get(activeBuffer.name); activeUser = activeServer.users.get(activeBuffer.name);
} }
@ -2091,7 +2010,7 @@ export default class App extends Component {
} }
let memberList = null; let memberList = null;
if (activeBuffer && activeBuffer.type === BufferType.CHANNEL) { if (activeBuffer && activeBuffer.type == BufferType.CHANNEL) {
memberList = html` memberList = html`
<section <section
id="member-list" id="member-list"
@ -2276,6 +2195,7 @@ export default class App extends Component {
<${Buffer} <${Buffer}
buffer=${activeBuffer} buffer=${activeBuffer}
server=${activeServer} server=${activeServer}
bouncerNetwork=${activeBouncerNetwork}
settings=${this.state.settings} settings=${this.state.settings}
onChannelClick=${this.handleChannelClick} onChannelClick=${this.handleChannelClick}
onNickClick=${this.handleNickClick} onNickClick=${this.handleNickClick}
@ -2288,7 +2208,6 @@ export default class App extends Component {
${memberList} ${memberList}
<${Composer} <${Composer}
ref=${this.composer} ref=${this.composer}
client=${activeClient}
readOnly=${composerReadOnly} readOnly=${composerReadOnly}
onSubmit=${this.handleComposerSubmit} onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete} autocomplete=${this.autocomplete}

View File

@ -19,7 +19,7 @@ export default class NetworkForm extends Component {
handleInput(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }

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";
@ -214,7 +214,7 @@ export default function BufferHeader(props) {
} }
let name = props.buffer.name; let name = props.buffer.name;
if (props.buffer.type === BufferType.SERVER) { if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork); name = getServerName(props.server, props.bouncerNetwork);
} }

View File

@ -1,6 +1,5 @@
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, ServerStatus, getBufferURL, getServerName } from "../state.js"; import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
function BufferItem(props) { function BufferItem(props) {
@ -16,20 +15,18 @@ 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) { if (props.buffer.type === BufferType.SERVER) {
case BufferType.SERVER:
let isError = props.server.status === ServerStatus.DISCONNECTED; let isError = props.server.status === ServerStatus.DISCONNECTED;
if (props.bouncerNetwork && props.bouncerNetwork.error) { if (props.bouncerNetwork && props.bouncerNetwork.error) {
isError = true; isError = true;
@ -37,20 +34,12 @@ function BufferItem(props) {
if (isError) { if (isError) {
classes.push("error"); classes.push("error");
} }
break;
case BufferType.NICK:
let user = props.server.users.get(name);
if (user && irc.isMeaningfulRealname(user.realname, name)) {
title = stripANSI(user.realname);
}
break;
} }
return html` return html`
<li class="${classes.join(" ")}"> <li class="${classes.join(" ")}">
<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,6 +47,7 @@ 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);
@ -75,7 +65,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

@ -21,19 +21,9 @@ 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=${irc.formatURL({ entity: 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>
`; `;
} }
@ -43,7 +33,7 @@ function _Timestamp({ date, url, showSeconds }) {
if (showSeconds) { if (showSeconds) {
timestamp += ":--"; timestamp += ":--";
} }
return html`<span class="timestamp">${timestamp}</span>`; return html`<spam class="timestamp">${timestamp}</span>`;
} }
let hh = date.getHours().toString().padStart(2, "0"); let hh = date.getHours().toString().padStart(2, "0");
@ -94,7 +84,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,11 +98,7 @@ 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) {
@ -134,7 +120,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 +129,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);
@ -201,94 +182,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];
@ -369,7 +275,7 @@ class LogLine extends Component {
content = html`${createNick(buf.name)} is offline`; content = html`${createNick(buf.name)} is offline`;
break; 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 +325,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)}
/>
`; `;
} }
@ -684,17 +585,18 @@ export default class Buffer extends Component {
} }
let server = this.props.server; let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let settings = this.props.settings; let settings = this.props.settings;
let serverName = server.name; let serverName = server.name;
let children = []; let children = [];
if (buf.type === BufferType.SERVER) { if (buf.type == BufferType.SERVER) {
children.push(html`<${NotificationNagger}/>`); children.push(html`<${NotificationNagger}/>`);
} }
if (buf.type === BufferType.SERVER && server.isBouncer && !server.bouncerNetID) { if (buf.type == BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`); children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`);
} }
if (buf.type === BufferType.SERVER && server.status === ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) { if (buf.type == BufferType.SERVER && server.status == ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) {
children.push(html` children.push(html`
<${AccountNagger} <${AccountNagger}
server=${server} server=${server}
@ -715,7 +617,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 +624,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 +635,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);
} }
@ -808,7 +676,7 @@ export default class Buffer extends Component {
} }
} }
if (!hasUnreadSeparator && buf.type !== BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) { 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,7 +689,7 @@ export default class Buffer extends Component {
if (sep.length > 0) { if (sep.length > 0) {
children.push(createFoldGroup(foldMessages)); children.push(createFoldGroup(foldMessages));
children.push(...sep); children.push(sep);
foldMessages = []; foldMessages = [];
} }

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,108 +116,6 @@ 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.activeElement !== document.body) {
@ -289,11 +173,6 @@ export default class Composer extends Component {
return; return;
} }
if (event.clipboardData.files.length > 0) {
this.handleInputPaste(event);
return;
}
let text = event.clipboardData.getData("text"); let text = event.clipboardData.getData("text");
event.preventDefault(); event.preventDefault();
@ -349,9 +228,6 @@ export default class Composer extends Component {
placeholder=${placeholder} placeholder=${placeholder}
enterkeyhint="send" enterkeyhint="send"
onKeyDown=${this.handleInputKeyDown} onKeyDown=${this.handleInputKeyDown}
onPaste=${this.handleInputPaste}
onDragOver=${this.handleDragOver}
onDrop=${this.handleDrop}
maxlength=${this.props.maxLen} maxlength=${this.props.maxLen}
/> />
</form> </form>

View File

@ -34,7 +34,7 @@ export default class ConnectForm extends Component {
handleInput(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }

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";
@ -26,11 +26,6 @@ function KeyBindingsHelp() {
`; `;
}); });
l.push(html`
<dt><kbd>Tab</kbd></dt>
<dd>Automatically complete nickname or channel</dd>
`);
if (!window.matchMedia("(pointer: none)").matches) { if (!window.matchMedia("(pointer: none)").matches) {
l.push(html` l.push(html`
<dt><strong>Middle mouse click</strong></dt> <dt><strong>Middle mouse click</strong></dt>
@ -42,8 +37,8 @@ function KeyBindingsHelp() {
} }
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 = [html`<strong>/${name}</strong>`];
if (cmd.usage) { if (cmd.usage) {

View File

@ -18,7 +18,7 @@ export default class JoinForm extends Component {
handleInput(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }

View File

@ -22,7 +22,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) {

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

@ -37,7 +37,7 @@ export default class NetworkForm extends Component {
handleInput(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@ -46,10 +46,10 @@ export default class NetworkForm extends Component {
let params = {}; let params = {};
Object.keys(defaultParams).forEach((k) => { Object.keys(defaultParams).forEach((k) => {
if (!this.props.isNew && this.prevParams[k] === this.state[k]) { if (!this.props.isNew && this.prevParams[k] == this.state[k]) {
return; return;
} }
if (this.props.isNew && defaultParams[k] === this.state[k]) { if (this.props.isNew && defaultParams[k] == this.state[k]) {
return; return;
} }
params[k] = this.state[k]; params[k] = this.state[k];

View File

@ -15,7 +15,7 @@ export default class RegisterForm extends Component {
handleInput(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }

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,7 +29,7 @@ export default class ScrollManager extends Component {
} }
} }
store.set(scrollKey, stickToKey); store.set(this.props.scrollKey, stickToKey);
} }
restoreScrollPosition() { restoreScrollPosition() {
@ -48,13 +48,13 @@ export default class ScrollManager extends Component {
} }
} }
if (target.scrollTop === 0) { if (target.scrollTop == 0) {
this.props.onScrollTop(); this.props.onScrollTop();
} }
} }
handleScroll() { handleScroll() {
if (this.props.target.current.scrollTop === 0) { if (this.props.target.current.scrollTop == 0) {
this.props.onScrollTop(); this.props.onScrollTop();
} }
} }
@ -64,9 +64,9 @@ export default class ScrollManager extends Component {
this.props.target.current.addEventListener("scroll", this.handleScroll); this.props.target.current.addEventListener("scroll", this.handleScroll);
} }
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 +79,7 @@ export default class ScrollManager extends Component {
componentWillUnmount() { componentWillUnmount() {
this.props.target.current.removeEventListener("scroll", this.handleScroll); this.props.target.current.removeEventListener("scroll", this.handleScroll);
this.saveScrollPosition(this.props.scrollKey); this.saveScrollPosition();
} }
render() { render() {

View File

@ -15,7 +15,7 @@ export default class SettingsForm extends Component {
handleInput(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }, () => { this.setState({ [target.name]: value }, () => {
this.props.onChange(this.state); this.props.onChange(this.state);
}); });

View File

@ -15,7 +15,7 @@ export default class RegisterForm extends Component {
handleInput(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value; let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }

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

@ -15,9 +15,6 @@ gamja can be configured using a `config.json` file at the root. Example:
} }
``` ```
Errors while parsing the configuration file are logged in the
[browser's web console].
## IRC server ## IRC server
The `server` object configures the IRC server. The `server` object configures the IRC server.
@ -46,5 +43,3 @@ The `oauth2` object configures OAuth 2.0 authentication.
- `client_id` (string): OAuth 2.0 client ID. - `client_id` (string): OAuth 2.0 client ID.
- `client_secret` (string): OAuth 2.0 client secret. - `client_secret` (string): OAuth 2.0 client secret.
- `scope` (string): OAuth 2.0 scope. - `scope` (string): OAuth 2.0 scope.
[browser's web console]: https://firefox-source-docs.mozilla.org/devtools-user/web_console/index.html

View File

@ -5,9 +5,8 @@ the same HTTP server is used as a reverse proxy for the IRC WebSocket.
## [soju] ## [soju]
Add a WebSocket listener to soju, e.g. `listen ws+insecure://127.0.0.1:8080`. Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`. Then
Then configure your reverse proxy to serve gamja files and proxy `/socket` to configure your reverse proxy to serve gamja files and proxy `/socket` to soju.
soju.
## [webircgateway] ## [webircgateway]
@ -39,7 +38,6 @@ location /socket {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }

View File

@ -3,11 +3,10 @@
gamja settings can be overridden using URL query parameters: gamja settings can be overridden using URL query parameters:
- `server`: path or URL to the WebSocket server - `server`: path or URL to the WebSocket server
- `nick`: nickname (if the character `*` appears in the string, it will be - `nick`: nickname
replaced with a randomly generated value)
- `channels`: comma-separated list of channels to join (`#` needs to be escaped) - `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open - `open`: [IRC URL] to open
- `debug`: enable debug logs if set to `1`, disable debug logs if set to `0` - `debug`: if set to 1, debug mode is enabled
Alternatively, the channels can be set with the URL fragment (ie, by just Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL). appending the channel name to the gamja URL).

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, receiptFromMessage } from "./state.js";
function getSiblingBuffer(buffers, bufID, delta) { function getSiblingBuffer(buffers, bufID, delta) {
let bufList = Array.from(buffers.values()); let bufList = Array.from(buffers.values());
@ -40,8 +40,6 @@ export const keybindings = [
}); });
}); });
return { buffers }; return { buffers };
}, () => {
app.updateDocumentTitle();
}); });
}, },
}, },
@ -121,9 +119,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

@ -51,7 +51,7 @@ export function strip(text) {
if (isDigit(text[i + 1])) { if (isDigit(text[i + 1])) {
i++; i++;
} }
if (text[i + 1] === "," && isDigit(text[i + 2])) { if (text[i + 1] == "," && isDigit(text[i + 2])) {
i += 2; i += 2;
if (isDigit(text[i + 1])) { if (isDigit(text[i + 1])) {
i++; i++;
@ -63,7 +63,7 @@ export function strip(text) {
break; break;
} }
i += HEX_COLOR_LENGTH; i += HEX_COLOR_LENGTH;
if (text[i + 1] === "," && isHexColor(text.slice(i + 2))) { if (text[i + 1] == "," && isHexColor(text.slice(i + 2))) {
i += 1 + HEX_COLOR_LENGTH; i += 1 + HEX_COLOR_LENGTH;
} }
break; break;

View File

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

View File

@ -1,4 +1,5 @@
import * as irc from "./irc.js"; import * as irc from "./irc.js";
import * as base64 from "./base64.js";
// Static list of capabilities that are always requested when supported by the // Static list of capabilities that are always requested when supported by the
// server // server
@ -21,7 +22,6 @@ const permanentCaps = [
"draft/account-registration", "draft/account-registration",
"draft/chathistory", "draft/chathistory",
"draft/extended-monitor", "draft/extended-monitor",
"draft/message-redaction",
"draft/read-marker", "draft/read-marker",
"soju.im/bouncer-networks", "soju.im/bouncer-networks",
@ -75,8 +75,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 +87,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 {
@ -135,7 +135,6 @@ export default class Client extends EventTarget {
autoReconnect = true; autoReconnect = true;
reconnectTimeoutID = null; reconnectTimeoutID = null;
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC); reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
lastReconnectDate = new Date(0);
pingIntervalID = null; pingIntervalID = null;
pendingCmds = { pendingCmds = {
WHO: Promise.resolve(null), WHO: Promise.resolve(null),
@ -149,8 +148,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 +160,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);
@ -206,16 +202,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 +227,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,13 +246,6 @@ 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);
@ -301,13 +287,11 @@ 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) {
@ -326,6 +310,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 +354,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 +425,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 +433,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 +465,18 @@ 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 = base64.encode("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break; break;
case "EXTERNAL": case "EXTERNAL":
initialResp = ""; initialResp = { command: "AUTHENTICATE", params: ["+"] };
break; break;
case "OAUTHBEARER": case "OAUTHBEARER":
initialResp = "n,,\x01auth=Bearer " + params.token + "\x01\x01"; let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
initialResp = { command: "AUTHENTICATE", params: [base64.encode(raw)] };
break; break;
default: default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`); throw new Error(`Unknown authentication mechanism '${mechanism}'`);
@ -507,9 +495,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 +529,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;
@ -671,7 +654,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;
} }
@ -725,10 +708,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 +725,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) {
@ -753,7 +735,7 @@ export default class Client extends EventTarget {
isNick(name) { isNick(name) {
// A dollar sign is used for server-wide broadcasts // A dollar sign is used for server-wide broadcasts
return !this.isServer(name) && !this.isChannel(name) && !name.startsWith("$"); return !this.isServer(name) && !this.isChannel(name) && !name.startsWith('$');
} }
setPingInterval(sec) { setPingInterval(sec) {
@ -790,7 +772,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,9 +821,7 @@ 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);
}); });
@ -854,7 +834,7 @@ export default class Client extends EventTarget {
} }
let msg = { let msg = {
command: "JOIN", command: "JOIN",
params, params: params,
}; };
return this.roundtrip(msg, (msg) => { return this.roundtrip(msg, (msg) => {
switch (msg.command) { switch (msg.command) {
@ -880,6 +860,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;
@ -941,7 +922,7 @@ export default class Client extends EventTarget {
} }
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 await this.fetchHistoryBetween(target, after, before, limit);
} }
return { messages }; return { messages };

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";
@ -74,24 +72,8 @@ 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",
"@": "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]);
@ -272,11 +260,10 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
const alphaNum = (() => { const alphaNum = (() => {
try { try {
return new RegExp(/^[\p{L}0-9]$/, "u"); return new RegExp(/^[\p{L}0-9]$/, "u");
} catch (_e) { } catch (e) {
return new RegExp(/^[a-zA-Z0-9]$/, "u"); return new RegExp(/^[a-zA-Z0-9]$/, "u");
} }
})(); })();
const space = new RegExp(/^\s$/);
function isWordBoundary(ch) { function isWordBoundary(ch) {
switch (ch) { switch (ch) {
@ -289,39 +276,14 @@ function isWordBoundary(ch) {
} }
} }
function isURIPrefix(text) {
for (let i = text.length - 1; i >= 0; i--) {
if (space.test(text[i])) {
text = text.slice(i);
break;
}
}
let i = text.indexOf("://");
if (i <= 0) {
return false;
}
// See RFC 3986 section 3
let ch = text[i - 1];
switch (ch) {
case "+":
case "-":
case ".":
return true;
default:
return alphaNum.test(ch);
}
}
export function isHighlight(msg, nick, cm) { export function isHighlight(msg, nick, cm) {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") { if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
return false; return false;
} }
nick = cm(nick); nick = cm(nick);
if (msg.prefix && cm(msg.prefix.name) === nick) { if (msg.prefix && cm(msg.prefix.name) == nick) {
return false; // Our own messages aren't highlights return false; // Our own messages aren't highlights
} }
@ -340,7 +302,7 @@ export function isHighlight(msg, nick, cm) {
if (i + nick.length < text.length) { if (i + nick.length < text.length) {
right = text[i + nick.length]; right = text[i + nick.length];
} }
if (isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text.slice(0, i))) { if (isWordBoundary(left) && isWordBoundary(right)) {
return true; return true;
} }
@ -349,7 +311,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 +349,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;
} }
@ -507,7 +469,7 @@ export class Isupport {
return stdChanModes; return stdChanModes;
} }
let chanModes = this.raw.get("CHANMODES").split(","); let chanModes = this.raw.get("CHANMODES").split(",");
if (chanModes.length !== 4) { if (chanModes.length != 4) {
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES")); console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
return stdChanModes; return stdChanModes;
} }
@ -538,10 +500,6 @@ export class Isupport {
} }
return parseInt(this.raw.get("LINELEN"), 10); return parseInt(this.raw.get("LINELEN"), 10);
} }
filehost() {
return this.raw.get("SOJU.IM/FILEHOST");
}
} }
export function getMaxPrivmsgLen(isupport, nick, target) { export function getMaxPrivmsgLen(isupport, nick, target) {
@ -572,13 +530,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 +550,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;
@ -954,19 +912,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

@ -43,9 +43,9 @@ export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope
// TODO: use the state param to prevent cross-site request // TODO: use the state param to prevent cross-site request
// forgery // forgery
let params = { let params = {
"response_type": "code", response_type: "code",
"client_id": clientId, client_id: clientId,
"redirect_uri": redirectUri, redirect_uri: redirectUri,
}; };
if (scope) { if (scope) {
params.scope = scope; params.scope = scope;
@ -66,12 +66,12 @@ function buildPostHeaders(clientId, clientSecret) {
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) { export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
let data = { let data = {
"grant_type": "authorization_code", grant_type: "authorization_code",
code, code,
"redirect_uri": redirectUri, redirect_uri: redirectUri,
}; };
if (!clientSecret) { if (!clientSecret) {
data["client_id"] = clientId; data.client_id = clientId;
} }
let resp = await fetch(serverMetadata.token_endpoint, { let resp = await fetch(serverMetadata.token_endpoint, {

3665
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,8 +16,7 @@
}, },
"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": {

View File

@ -136,60 +136,23 @@ 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)) {
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 a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
} }
return 0;
}
function updateMembership(membership, letter, add, client) { function updateMembership(membership, letter, add, client) {
let prefix = client.isupport.prefix(); let prefix = client.isupport.prefix();
@ -215,7 +178,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);
@ -355,7 +318,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;
@ -375,11 +338,10 @@ export const State = {
hasInitialWho: false, // if channel hasInitialWho: false, // if channel
members: new irc.CaseMapMap(null, client.cm), // if channel members: new irc.CaseMapMap(null, client.cm), // if channel
messages: [], messages: [],
redacted: new Set(),
unread: Unread.NONE, unread: Unread.NONE,
prevReadReceipt: null, prevReadReceipt: null,
}); });
bufferList = bufferList.sort((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 }];
}, },
@ -432,7 +394,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,9 +452,10 @@ export const State = {
}); });
return { members }; return { members };
}); });
break;
case irc.RPL_ENDOFWHO: case irc.RPL_ENDOFWHO:
target = msg.params[1]; target = msg.params[1];
if (msg.list.length === 0 && !client.isChannel(target) && target.indexOf("*") < 0) { if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
// Not a channel nor a mask, likely a nick // Not a channel nor a mask, likely a nick
return updateUser(target, (user) => { return updateUser(target, (user) => {
return { offline: true }; return { offline: true };
@ -520,11 +483,12 @@ export const State = {
return { users }; return { users };
}); });
} }
break;
case "JOIN": case "JOIN":
channel = msg.params[0]; channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) { if (client.isMyNick(msg.prefix.name)) {
let [_id, update] = State.createBuffer(state, channel, serverID, client); let [id, update] = State.createBuffer(state, channel, serverID, client);
state = { ...state, ...update }; state = { ...state, ...update };
} }
@ -582,7 +546,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 +572,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 +613,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 +644,13 @@ 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:
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: msg.command == irc.RPL_MONOFFLINE });
state = { ...state, ...update }; state = { ...state, ...update };
} }

View File

@ -154,6 +154,10 @@ button.danger:hover {
padding: 2px 10px; padding: 2px 10px;
box-sizing: border-box; box-sizing: border-box;
} }
#buffer-list li.active a {
color: white;
background-color: var(--gray);
}
#buffer-list li.error a { #buffer-list li.error a {
color: red; color: red;
} }
@ -163,10 +167,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;
} }
@ -302,7 +302,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 {
@ -364,14 +364,8 @@ 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 +388,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);
@ -597,16 +590,16 @@ ul.switcher-list .server {
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;
@ -636,6 +629,11 @@ ul.switcher-list .server {
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);
} }