62 Commits

Author SHA1 Message Date
Simon Ser
54453c5f44 Fix invalid relative import
Worked locally because it's served at the root…
2022-06-27 17:16:33 +02:00
Simon Ser
fa80a56516 Add button to enable protocol handler in settings 2022-06-27 17:01:15 +02:00
Simon Ser
7cabb6f85b Add a setting for seconds in timestamps 2022-06-27 16:34:41 +02:00
Simon Ser
505a6fd5ab Workaround the sad state of base64 web APIs
This is necessary to make usernames/passwords with UTF-8 in them
work correctly.
2022-06-24 23:59:18 +02:00
Simon Ser
8e30806fec Upgrade dependencies 2022-06-14 19:58:50 +02:00
Simon Ser
f0c398a10c components/buffer-header: print bouncer network error if any 2022-06-09 15:54:29 +02:00
Simon Ser
baaf576d82 Add a settings dialog
Add an option to hide chat events or always expand them.

Closes: https://todo.sr.ht/~emersion/gamja/73
2022-06-08 16:57:16 +02:00
Simon Ser
e3c2d85a94 Fix ping config lost in ConnectForm
Reported-by: xse <xse@riseup.net>
References: https://lists.sr.ht/~emersion/public-inbox/patches/32126
2022-06-08 15:14:06 +02:00
Umar Getagazov
576b9d51eb components/app: switch to server buffer on close only if active
If the buffer that's being closed is not the active one, there's no
point in switching the user away to another buffer.
2022-06-08 15:05:26 +02:00
Simon Ser
6b04cb1417 Add support for bot mode
References: https://ircv3.net/specs/extensions/bot-mode
2022-06-08 15:04:27 +02:00
Simon Ser
8507500d74 components/scroll-manager: don't crash when Buffer is empty 2022-04-22 12:32:54 +02:00
Simon Ser
aaef4e1629 store: use lower-case for buffer keys 2022-04-22 12:04:11 +02:00
Simon Ser
cdd2da90a9 Update webpage title when switching buffer 2022-04-22 11:49:23 +02:00
Simon Ser
4a981997f0 Handle CHATHISTORY messages when reaching end of batch
Closes: https://todo.sr.ht/~emersion/gamja/115
2022-04-22 11:25:41 +02:00
Simon Ser
f45b51d981 commands: fix TypeError in kickban
The ban variable was undefined.
2022-04-14 10:53:35 +02:00
Simon Ser
73db1a888e Upgrade dependencies 2022-02-26 10:47:55 +01:00
Simon Ser
3dc98ec797 Convert remaining simple quotes to double quotes 2022-02-26 09:54:31 +01:00
Simon Ser
e37d5f363b lib/irc: fix bound check in isHighlight
Doesn't seem like this was causing any issues, but let's fix the
logic regardless.
2022-02-25 11:38:00 +01:00
Simon Ser
221b1b6356 lib/irc: remove unnecessary non-breaking-space case
Handled by the default case already.
2022-02-25 11:37:18 +01:00
Simon Ser
86b1030b7a lib/irc: add missing num range to alphaNum regexp 2022-02-25 11:36:43 +01:00
Simon Ser
08578c9a21 components/app: fix missing semicolons 2022-02-21 15:26:12 +01:00
Simon Ser
26cc073f41 store: save buffer state when user navigates away
Avoids loosing some state on page unload.
2022-02-18 18:22:00 +01:00
Simon Ser
9e703698ca lib/irc: drop outdated CapRegistry TODO 2022-02-16 15:46:22 +01:00
Simon Ser
37d7f4a1c5 Refactor backlog fetching into function 2022-02-13 15:34:11 +01:00
Simon Ser
962c05c066 Prevent hole in history when reconnecting 2022-02-13 15:26:04 +01:00
Simon Ser
f2c9fd1d7f Update stored unread status on READ message 2022-02-12 10:24:56 +01:00
Simon Ser
a3eec9a351 store: add note about comparison in Buffer.put 2022-02-12 10:24:34 +01:00
Simon Ser
2ac7be6218 state: add isReceiptBefore 2022-02-12 10:21:11 +01:00
Simon Ser
5f8cd976e6 keybindings: fix error on alt+h
Fixes the following JS error:

    TypeError: e.setReceipt is not a function
2022-02-12 10:05:58 +01:00
Simon Ser
fbc42b6dab components/app: move lastErrorID declaration down
Move it right before App, rather than drown in-between unrelated
functions.
2022-02-11 21:17:35 +01:00
Simon Ser
dc398baa3b components/app: stop updating prevReadReceipt on READ message
prevReadReceipt is used for the unread marker. Let's not update it
before the user switches the current buffer.
2022-02-11 21:09:11 +01:00
Simon Ser
6a9a8e88f1 store: fix no-op read receipt update detection
If the old and new times are equal, the update is a no-op.
2022-02-11 21:07:49 +01:00
Simon Ser
f47d93af8a Don't fetch backlog before read receipt 2022-02-11 21:02:34 +01:00
Simon Ser
fce0936c20 components/app: introduce getReceipt 2022-02-11 20:59:31 +01:00
Simon Ser
0636544c40 components/app: close notifications when receiving READ message 2022-02-11 19:32:30 +01:00
Simon Ser
7c6f334dbf components/app: close notifications when switching buffer 2022-02-11 19:32:30 +01:00
Simon Ser
7ddd783150 components/app: make showNotification return null on error
We'll do more involved stuff with notifications soon, and don't
want to deal with buggy notification objects.
2022-02-11 19:32:30 +01:00
Simon Ser
bb42ff6a07 components/app: include server ID in notification tags 2022-02-11 19:32:30 +01:00
Simon Ser
db0ef39c6b Add support for soju.im/read 2022-02-11 19:32:26 +01:00
Simon Ser
77f54080e7 Make delivery receipts follow read receipts
If a message has been read, it's been delivered.

Fixes #23 at least partially.

References: https://todo.sr.ht/~emersion/gamja/23
2022-02-11 19:29:55 +01:00
Simon Ser
065b3f21fc Refactor receipts
They are now saved in the buffer store to allow for proper server
separation.
2022-02-11 19:29:55 +01:00
Simon Ser
d2bcea8c86 Introduce isMessageBeforeReceipt 2022-02-11 16:37:58 +01:00
Simon Ser
3d81466788 components/app: introduce receiptFromMessage 2022-02-11 16:30:46 +01:00
Simon Ser
f2923452c1 store: debounce buffer store saves 2022-02-11 16:24:32 +01:00
Simon Ser
39c36e7a7b Fix unread marker going back
Receipts must never go back in time.

Fixes: c428e504fe ("Don't show unread marker for outgoing messages")
2022-02-11 16:06:06 +01:00
Simon Ser
e91b044134 components/app: make switchBuffer state changes atomic
Instead of calling App.setBufferState inside the App.setState
callback invoked when the update is done, call State.updateBuffer.
2022-02-11 15:48:56 +01:00
delthas
4cb3abfa72 components/connect-form: make the server password field password-typed 2022-02-11 12:58:26 +01:00
Simon Ser
0063a5a372 Set min node version in package.json
v14.13.0 is required for CommonJS named imports to work properly.
2022-02-10 14:46:42 +01:00
Дамјан Георгиевски
1142145c6d fix ping after reconnect
client.setPingInterval was only called once in app.connect(),
but client.disconnect() disables it, and the ping timer is never again set,
even though the client can reconnect.

the change passes the ping time as a parameter to the client, and the
client calls setPingInterval() after a successful WS open event.
2022-02-04 15:54:23 +01:00
Simon Ser
f465e24adf components/buffer-header: fix dead space above description 2022-02-04 14:38:28 +01:00
Simon Ser
7f7a7c1aac components/buffer-list: remove pointless temporary variable 2022-02-04 14:32:29 +01:00
Simon Ser
e1bbe34ff2 state: add bouncerNetworks helpers 2022-02-04 14:22:50 +01:00
delthas
fab42ba2ee commands: add password param to /join 2022-02-02 20:45:18 +01:00
Simon Ser
9f93e200ed commands: add comment param to /kick usage 2022-01-31 18:30:48 +01:00
Simon Ser
bd48f36ade lib/irc: add missing Isupport.chanModes
It was called by forEachChannelModeUpdate, but wasn't implemented.
2022-01-31 18:24:34 +01:00
xse
393fd93253 components/buffer: use browser locale for date-separator 2022-01-14 23:26:05 +01:00
Simon Ser
a0f8f1f52f components/buffer: fix INVITE link
It was throwing a TypeError.
2022-01-10 10:32:37 +01:00
Simon Ser
5d6de11a4c commands: simplify /who usage string
As per https://modern.ircdocs.horse/#who-message
2022-01-09 19:30:01 +01:00
Simon Ser
6692ed0035 components/help: use bold for command name only 2022-01-09 19:28:23 +01:00
Simon Ser
5e34067d38 components/help: remove "/" keybinding, document middle mouse click 2022-01-09 19:20:44 +01:00
Isaac Freund
690845c2af Better handle long topics on small screen sizes
Currently long topics will cause the buffer header to take up an
arbitrarily large percentage of the screen on mobile. Additionaly, long
words like URLS are not broken and may cause the buffer header to extend
outside of the viewport in the x direction, rendering the buffer content
unreadable.

This patch fixes these two issues by limiting the buffer header size to
20% of the viewport and breaking long words such as URLs if they would
overflow.

Fixes: https://todo.sr.ht/~emersion/gamja/129
2022-01-07 16:02:33 +01:00
Noelle Leigh
0b59cf92b9 Display persistant command input on server buffer
This commit changes the composer to not be read-only on the server
buffer, which tells the user that they can send commands from that view.

On the server buffer, the placeholder is changed to
"Type a command (see /help)", which indicates to the user that this buffer
only accepts commands, and gives them a hint for how to learn what
commands are available.

Implements: https://todo.sr.ht/~emersion/gamja/38
2021-12-21 10:44:24 +01:00
19 changed files with 2626 additions and 11999 deletions

View File

@@ -54,19 +54,23 @@ function markServerBufferUnread(app) {
}
const join = {
usage: "<name>",
usage: "<name> [password]",
description: "Join a channel",
execute: (app, args) => {
let channel = args[0];
if (!channel) {
throw new Error("Missing channel name");
}
app.open(channel);
if (args.length > 1) {
app.open(channel, null, args[1]);
} else {
app.open(channel);
}
},
};
const kick = {
usage: "<nick>",
usage: "<nick> [comment]",
description: "Remove a user from the channel",
execute: (app, args) => {
let nick = args[0];
@@ -79,6 +83,22 @@ const kick = {
},
};
const ban = {
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => {
if (args.length == 0) {
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({
command: "MODE",
params: [activeChannel, "+b"],
});
} else {
return setUserHostMode(app, args, "+b");
}
},
};
function givemode(app, args, mode) {
// TODO: Handle several users at once
let nick = args[0];
@@ -104,21 +124,7 @@ export default {
getActiveClient(app).send({command: "AWAY", params});
},
},
"ban": {
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => {
if (args.length == 0) {
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({
command: "MODE",
params: [activeChannel, "+b"],
});
} else {
return setUserHostMode(app, args, "+b");
}
},
},
"ban": ban,
"buffer": {
usage: "<name>",
description: "Switch to a buffer",
@@ -393,7 +399,7 @@ export default {
execute: (app, args) => givemode(app, args, "+v"),
},
"who": {
usage: "[<mask> [o]]",
usage: "<mask>",
description: "Retrieve a list of users",
execute: (app, args) => {
getActiveClient(app).send({ command: "WHO", params: args });

View File

@@ -11,12 +11,13 @@ import NetworkForm from "./network-form.js";
import AuthForm from "./auth-form.js";
import RegisterForm from "./register-form.js";
import VerifyForm from "./verify-form.js";
import SettingsForm from "./settings-form.js";
import Composer from "./composer.js";
import ScrollManager from "./scroll-manager.js";
import Dialog from "./dialog.js";
import { html, Component, createRef } from "../lib/index.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { SERVER_BUFFER, BufferType, ReceiptType, ServerStatus, Unread, State, getServerName } from "../state.js";
import { SERVER_BUFFER, BufferType, ReceiptType, ServerStatus, Unread, BufferEventsDisplayMode, State, getServerName, receiptFromMessage, isReceiptBefore, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import commands from "../commands.js";
import { setup as setupKeybindings } from "../keybindings.js";
import * as store from "../store.js";
@@ -60,11 +61,11 @@ function isProduction() {
function parseQueryString() {
let query = window.location.search.substring(1);
let params = {};
query.split('&').forEach((s) => {
query.split("&").forEach((s) => {
if (!s) {
return;
}
let pair = s.split('=');
let pair = s.split("=");
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
});
return params;
@@ -118,20 +119,9 @@ function fillConnectParams(params) {
return params;
}
function debounce(f, delay) {
let timeout = null;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
f(...args);
}, delay);
};
}
function showNotification(title, options) {
if (!window.Notification || Notification.permission !== "granted") {
return new EventTarget();
return null;
}
// This can still fail due to:
@@ -140,10 +130,32 @@ function showNotification(title, options) {
return new Notification(title, options);
} catch (err) {
console.error("Failed to show notification: ", err);
return new EventTarget();
return null;
}
}
function getReceipt(stored, type) {
if (!stored || !stored.receipts) {
return null;
}
return stored.receipts[ReceiptType.READ];
}
function getLatestReceipt(bufferStore, server, type) {
let buffers = bufferStore.list(server);
let last = null;
for (let buf of buffers) {
if (buf.name === "*") {
continue;
}
let receipt = getReceipt(buf, type);
if (isReceiptBefore(last, receipt)) {
last = receipt;
}
}
return last;
}
let lastErrorID = 0;
export default class App extends Component {
@@ -159,8 +171,8 @@ export default class App extends Component {
saslExternal: false,
autoconnect: false,
autojoin: [],
ping: 0,
},
bouncerNetworks: new Map(),
connectForm: true,
loading: true,
dialog: null,
@@ -184,6 +196,8 @@ export default class App extends Component {
* confirmation for security reasons.
*/
autoOpenURL = null;
messageNotifications = new Set();
baseTitle = null;
constructor(props) {
super(props);
@@ -208,10 +222,14 @@ export default class App extends Component {
this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this);
this.handleVerifyClick = this.handleVerifyClick.bind(this);
this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
this.handleSettingsChange = this.handleSettingsChange.bind(this);
this.saveReceipts = debounce(this.saveReceipts.bind(this), 500);
this.state.settings = {
...this.state.settings,
...store.settings.load(),
};
this.receipts = store.receipts.load();
this.bufferStore = new store.Buffer();
configPromise.then((config) => {
@@ -254,6 +272,9 @@ export default class App extends Component {
if (config.server.auth === "external") {
connectParams.saslExternal = true;
}
if (typeof config.server.ping === "number") {
connectParams.ping = config.server.ping;
}
}
let autoconnect = store.autoconnect.load();
@@ -392,6 +413,20 @@ export default class App extends Component {
return id;
}
sendReadReceipt(client, storedBuffer) {
if (!client.caps.enabled.has("soju.im/read")) {
return;
}
let readReceipt = storedBuffer.receipts[ReceiptType.READ];
if (storedBuffer.name === "*" || !readReceipt) {
return;
}
client.send({
command: "READ",
params: [storedBuffer.name, "timestamp="+readReceipt.time],
});
}
switchBuffer(id) {
let buf;
this.setState((state) => {
@@ -399,87 +434,58 @@ export default class App extends Component {
if (!buf) {
return;
}
return { activeBuffer: buf.id };
let client = this.clients.get(buf.server);
let stored = this.bufferStore.get({ name: buf.name, server: client.params });
let prevReadReceipt = getReceipt(stored, ReceiptType.READ);
// TODO: only mark as read if user scrolled at the bottom
let update = State.updateBuffer(state, buf.id, {
unread: Unread.NONE,
prevReadReceipt,
});
return { ...update, activeBuffer: buf.id };
}, () => {
if (!buf) {
return;
}
let prevReadReceipt = this.getReceipt(buf.name, ReceiptType.READ);
// TODO: only mark as read if user scrolled at the bottom
this.setBufferState(buf.id, {
unread: Unread.NONE,
prevReadReceipt,
});
if (this.buffer.current) {
this.buffer.current.focus();
}
let client = this.clients.get(buf.server);
for (let notif of this.messageNotifications) {
if (client.cm(notif.data.bufferName) === client.cm(buf.name)) {
notif.close();
}
}
if (buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
this.setReceipt(buf.name, ReceiptType.READ, lastMsg);
let client = this.clients.get(buf.server);
this.bufferStore.put({
let stored = {
name: buf.name,
server: client.params,
unread: Unread.NONE,
});
receipts: { [ReceiptType.READ]: receiptFromMessage(lastMsg) },
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
}
let server = this.state.servers.get(buf.server);
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
});
}
saveReceipts() {
store.receipts.put(this.receipts);
}
getReceipt(target, type) {
let receipts = this.receipts.get(target);
if (!receipts) {
return undefined;
}
return receipts[type];
}
hasReceipt(target, type, msg) {
let receipt = this.getReceipt(target, type);
return receipt && msg.tags.time <= receipt.time;
}
setReceipt(target, type, msg) {
let receipt = this.getReceipt(target, type);
if (this.hasReceipt(target, type, msg)) {
return;
}
// TODO: this doesn't trigger a redraw
this.receipts.set(target, {
...this.receipts.get(target),
[type]: { time: msg.tags.time },
});
this.saveReceipts();
}
latestReceipt(type) {
let last = null;
this.receipts.forEach((receipts, target) => {
if (target === "*") {
return;
}
let delivery = receipts[type];
if (!delivery || !delivery.time) {
return;
}
if (!last || delivery.time > last.time) {
last = delivery;
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
document.title = this.baseTitle;
}
});
return last;
}
addMessage(serverID, bufName, msg) {
@@ -496,8 +502,12 @@ export default class App extends Component {
msg.tags.time = irc.formatDate(new Date());
}
let isDelivered = this.hasReceipt(bufName, ReceiptType.DELIVERED, msg);
let isRead = this.hasReceipt(bufName, ReceiptType.READ, msg);
let stored = this.bufferStore.get({ name: bufName, server: client.params });
let deliveryReceipt = getReceipt(stored, ReceiptType.DELIVERED);
let readReceipt = getReceipt(stored, ReceiptType.READ);
let isDelivered = isMessageBeforeReceipt(msg, deliveryReceipt);
let isRead = isMessageBeforeReceipt(msg, readReceipt);
// TODO: messages coming from infinite scroll shouldn't trigger notifications
if (client.isMyNick(msg.prefix.name)) {
@@ -528,12 +538,19 @@ export default class App extends Component {
let notif = showNotification(title, {
body: stripANSI(text),
requireInteraction: true,
tag: "msg," + msg.prefix.name + "," + bufName,
});
notif.addEventListener("click", () => {
// TODO: scroll to message
this.switchBuffer({ server: serverID, name: bufName });
tag: "msg,server=" + serverID + ",from=" + msg.prefix.name + ",to=" + bufName,
data: { bufferName: bufName, message: msg },
});
if (notif) {
notif.addEventListener("click", () => {
// TODO: scroll to message
this.switchBuffer({ server: serverID, name: bufName });
});
notif.addEventListener("close", () => {
this.messageNotifications.delete(notif);
});
this.messageNotifications.add(notif);
}
}
}
if (msg.command === "INVITE" && client.isMyNick(msg.params[0])) {
@@ -543,21 +560,30 @@ export default class App extends Component {
let notif = new Notification("Invitation to " + channel, {
body: msg.prefix.name + " has invited you to " + channel,
requireInteraction: true,
tag: "invite," + msg.prefix.name + "," + channel,
tag: "invite,server=" + serverID + ",from=" + msg.prefix.name + ",channel=" + channel,
actions: [{
action: "accept",
title: "Accept",
}],
});
notif.addEventListener("click", (event) => {
if (event.action === "accept") {
this.setReceipt(bufName, ReceiptType.READ, msg);
this.open(channel, serverID);
} else {
// TODO: scroll to message
this.switchBuffer({ server: serverID, name: bufName });
}
});
if (notif) {
notif.addEventListener("click", (event) => {
if (event.action === "accept") {
let stored = {
name: bufName,
server: client.params,
receipts: { [ReceiptType.READ]: receiptFromMessage(msg) },
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
this.open(channel, serverID);
} else {
// TODO: scroll to message
this.switchBuffer({ server: serverID, name: bufName });
}
});
}
}
// Open a new buffer if the message doesn't come from me or is a
@@ -566,36 +592,46 @@ export default class App extends Component {
this.createBuffer(serverID, bufName);
}
this.setReceipt(bufName, ReceiptType.DELIVERED, msg);
let bufID = { server: serverID, name: bufName };
this.setState((state) => State.addMessage(state, msg, bufID));
this.setBufferState(bufID, (buf) => {
// TODO: set unread if scrolled up
let unread = buf.unread;
let prevReadReceipt = buf.prevReadReceipt;
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer !== buf.id) {
unread = Unread.union(unread, msgUnread);
} else {
this.setReceipt(bufName, ReceiptType.READ, msg);
receipts[ReceiptType.READ] = receiptFromMessage(msg);
}
// Don't show unread marker for my own messages
if (client.isMyNick(msg.prefix.name)) {
prevReadReceipt = { time: msg.tags.time };
if (client.isMyNick(msg.prefix.name) && !isMessageBeforeReceipt(msg, prevReadReceipt)) {
prevReadReceipt = receiptFromMessage(msg);
}
this.bufferStore.put({
let stored = {
name: buf.name,
server: client.params,
unread,
});
receipts,
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
return { unread, prevReadReceipt };
});
}
connect(params) {
// Merge our previous connection params so that config options such as
// the ping interval are applied
params = {
...this.state.connectParams,
...params,
};
let serverID = null;
this.setState((state) => {
let update;
@@ -604,7 +640,10 @@ export default class App extends Component {
});
this.setState({ connectParams: params });
let client = new Client(fillConnectParams(params));
let client = new Client({
...fillConnectParams(params),
eventPlayback: this.state.settings.bufferEvents !== BufferEventsDisplayMode.HIDE,
});
client.debug = this.debug;
this.clients.set(serverID, client);
@@ -653,10 +692,6 @@ export default class App extends Component {
if (params.autojoin.length > 0) {
this.switchToChannel = params.autojoin[0];
}
if (this.config.server && typeof this.config.server.ping !== "undefined") {
client.setPingInterval(this.config.server.ping);
}
}
disconnect(serverID) {
@@ -830,6 +865,7 @@ export default class App extends Component {
case "CHATHISTORY":
case "ACK":
case "BOUNCER":
case "READ":
// Ignore these
return [];
default:
@@ -840,31 +876,18 @@ export default class App extends Component {
handleMessage(serverID, msg) {
let client = this.clients.get(serverID);
let destBuffers = this.routeMessage(serverID, msg);
if (irc.findBatchByType(msg, "chathistory")) {
destBuffers.forEach((bufName) => {
this.addMessage(serverID, bufName, msg);
});
return;
return; // Handled by the caller
}
let destBuffers = this.routeMessage(serverID, msg);
this.setState((state) => State.handleMessage(state, msg, serverID, client));
let target, channel;
switch (msg.command) {
case irc.RPL_WELCOME:
let lastReceipt = this.latestReceipt(ReceiptType.DELIVERED);
if (lastReceipt && lastReceipt.time && client.caps.enabled.has("draft/chathistory") && (!client.caps.enabled.has("soju.im/bouncer-networks") || client.params.bouncerNetwork)) {
let now = irc.formatDate(new Date());
client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => {
targets.forEach((target) => {
let from = lastReceipt;
let to = { time: msg.tags.time || now };
this.fetchBacklog(client, target.name, from, to);
});
});
}
this.fetchBacklog(serverID);
break;
case irc.RPL_ENDOFMOTD:
case irc.ERR_NOMOTD:
@@ -923,14 +946,6 @@ export default class App extends Component {
this.switchToChannel = null;
}
break;
case "PART":
channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) {
this.receipts.delete(channel);
this.saveReceipts();
}
break;
case "BOUNCER":
if (msg.params[0] !== "NETWORK") {
break; // We're only interested in network updates
@@ -950,16 +965,12 @@ export default class App extends Component {
let isNew = false;
this.setState((state) => {
let bouncerNetworks = new Map(state.bouncerNetworks);
if (!attrs) {
bouncerNetworks.delete(id);
return State.deleteBouncerNetwork(state, id);
} else {
let prev = bouncerNetworks.get(id);
isNew = prev === undefined;
attrs = { ...prev, ...attrs };
bouncerNetworks.set(id, attrs);
isNew = !state.bouncerNetworks.has(id);
return State.storeBouncerNetwork(state, id, attrs);
}
return { bouncerNetworks };
}, () => {
if (!attrs) {
let serverID = this.serverFromBouncerNetwork(id);
@@ -1004,6 +1015,55 @@ export default class App extends Component {
this.autoOpenURL = null;
}
break;
case "READ":
target = msg.params[0];
let bound = msg.params[1];
if (!client.isMyNick(msg.prefix.name) || bound === "*" || !bound.startsWith("timestamp=")) {
break;
}
let readReceipt = { time: bound.replace("timestamp=", "") };
let stored = this.bufferStore.get({ name: target, server: client.params });
if (isReceiptBefore(readReceipt, getReceipt(stored, ReceiptType.READ))) {
break;
}
for (let notif of this.messageNotifications) {
if (client.cm(notif.data.bufferName) !== client.cm(target)) {
continue;
}
if (isMessageBeforeReceipt(notif.data.message, readReceipt)) {
notif.close();
}
}
this.setBufferState({ server: serverID, name: target }, (buf) => {
// Re-compute unread status
let unread = Unread.NONE;
for (let i = buf.messages.length - 1; i >= 0; i--) {
let msg = buf.messages[i];
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
continue;
}
if (isMessageBeforeReceipt(msg, readReceipt)) {
break;
}
if (msg.isHighlight || client.isMyNick(buf.name)) {
unread = Unread.HIGHLIGHT;
break;
}
unread = Unread.MESSAGE;
}
this.bufferStore.put({
name: target,
server: client.params,
unread,
receipts: { [ReceiptType.READ]: readReceipt },
});
return { unread };
});
break;
default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
let description = msg.params[msg.params.length - 1];
@@ -1016,6 +1076,57 @@ export default class App extends Component {
});
}
fetchBacklog(serverID) {
let client = this.clients.get(serverID);
if (!client.caps.enabled.has("draft/chathistory")) {
return;
}
if (client.caps.enabled.has("soju.im/bouncer-networks") && !client.params.bouncerNetwork) {
return;
}
let lastReceipt = getLatestReceipt(this.bufferStore, client.params, ReceiptType.DELIVERED);
if (!lastReceipt) {
return;
}
let now = irc.formatDate(new Date());
client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => {
targets.forEach((target) => {
let from = lastReceipt;
let to = { time: now };
// Maybe we've just received a READ update from the
// server, avoid over-fetching history
let stored = this.bufferStore.get({ name: target.name, server: client.params });
let readReceipt = getReceipt(stored, ReceiptType.READ);
if (isReceiptBefore(from, readReceipt)) {
from = readReceipt;
}
// If we already have messages stored for the target,
// fetch all messages we've missed
let buf = State.getBuffer(this.state, { server: serverID, name: target.name });
if (buf && buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
from = receiptFromMessage(lastMsg);
}
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => {
for (let msg of result.messages) {
let destBuffers = this.routeMessage(serverID, msg);
for (let bufName of destBuffers) {
this.addMessage(serverID, target.name, msg);
}
}
}).catch((err) => {
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
this.showError("Failed to fetch backlog for '" + target.name + "'");
});
});
});
}
handleConnectSubmit(connectParams) {
this.dismissError();
@@ -1104,15 +1215,6 @@ export default class App extends Component {
this.open(nick);
}
fetchBacklog(client, target, after, before) {
client.fetchHistoryBetween(target, after, before, CHATHISTORY_MAX_SIZE).catch((err) => {
console.error("Failed to fetch backlog for '" + target + "': ", err);
this.showError("Failed to fetch backlog for '" + target + "'");
this.receipts.delete(target);
this.saveReceipts();
});
}
whoUserBuffer(target, serverID) {
let client = this.clients.get(serverID);
@@ -1120,9 +1222,16 @@ export default class App extends Component {
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
});
client.monitor(target);
if (client.caps.enabled.has("soju.im/read")) {
client.send({
command: "READ",
params: [target],
});
}
}
open(target, serverID) {
open(target, serverID, password) {
if (!serverID) {
serverID = State.getActiveServerID(this.state);
}
@@ -1132,7 +1241,7 @@ export default class App extends Component {
this.switchBuffer({ server: serverID });
} else if (client.isChannel(target)) {
this.switchToChannel = target;
client.join(target).catch((err) => {
client.join(target, password).catch((err) => {
this.showError(err);
});
} else {
@@ -1207,7 +1316,9 @@ export default class App extends Component {
}
// fallthrough
case BufferType.NICK:
this.switchBuffer({ name: SERVER_BUFFER });
if (this.state.activeBuffer === buf.id) {
this.switchBuffer({ name: SERVER_BUFFER });
}
this.setState((state) => {
let buffers = new Map(state.buffers);
buffers.delete(buf.id);
@@ -1216,14 +1327,15 @@ export default class App extends Component {
client.unmonitor(buf.name);
this.receipts.delete(buf.name);
this.saveReceipts();
this.bufferStore.delete({ name: buf.name, server: client.params });
break;
}
}
disconnectAll() {
this.close(this.state.buffers.keys().next().value);
}
executeCommand(s) {
let parts = s.split(" ");
let name = parts[0].toLowerCase().slice(1);
@@ -1419,6 +1531,9 @@ export default class App extends Component {
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
this.endOfHistory.set(buf.id, !result.more);
for (let msg of result.messages) {
this.addMessage(buf.server, buf.name, msg);
}
});
}
@@ -1581,10 +1696,32 @@ export default class App extends Component {
this.dismissDialog();
}
handleOpenSettingsClick() {
let showProtocolHandler = false;
for (let [id, client] of this.clients) {
if (client.caps.enabled.has("soju.im/bouncer-networks")) {
showProtocolHandler = true;
break;
}
}
this.openDialog("settings", { showProtocolHandler });
}
handleSettingsChange(settings) {
store.settings.put(settings);
this.setState({ settings });
}
componentDidMount() {
this.baseTitle = document.title;
setupKeybindings(this);
}
componentWillUnmount() {
document.title = this.baseTitle;
}
render() {
if (this.state.loading) {
return html`<section id="connect"></section>`;
@@ -1637,6 +1774,7 @@ export default class App extends Component {
onReconnect=${() => this.reconnect()}
onAddNetwork=${this.handleAddNetworkClick}
onManageNetwork=${() => this.handleManageNetworkClick(activeBuffer.server)}
onOpenSettings=${this.handleOpenSettingsClick}
/>
</section>
`;
@@ -1745,6 +1883,19 @@ export default class App extends Component {
</>
`;
break;
case "settings":
dialog = html`
<${Dialog} title="Settings" onDismiss=${this.dismissDialog}>
<${SettingsForm}
settings=${this.state.settings}
showProtocolHandler=${dialogData.showProtocolHandler}
onChange=${this.handleSettingsChange}
onDisconnect=${() => this.disconnectAll()}
onClose=${() => this.dismissDialog()}
/>
</>
`;
break;
}
let error = null;
@@ -1759,14 +1910,16 @@ export default class App extends Component {
}
let composerReadOnly = false;
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
composerReadOnly = true;
}
if (activeServer && activeServer.status !== ServerStatus.REGISTERED) {
composerReadOnly = true;
}
return html`
let commandOnly = false;
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
commandOnly = true;
}
let app = html`
<section
id="buffer-list"
class=${this.state.openPanels.bufferList ? "expand" : ""}
@@ -1799,6 +1952,7 @@ export default class App extends Component {
buffer=${activeBuffer}
server=${activeServer}
bouncerNetwork=${activeBouncerNetwork}
settings=${this.state.settings}
onChannelClick=${this.handleChannelClick}
onNickClick=${this.handleNickClick}
onAuthClick=${() => this.handleAuthClick(activeBuffer.server)}
@@ -1813,9 +1967,16 @@ export default class App extends Component {
readOnly=${composerReadOnly}
onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete}
commandOnly=${commandOnly}
/>
${dialog}
${error}
`;
return html`
<${SettingsContext.Provider} value=${this.state.settings}>
${app}
</>
`;
}
}

View File

@@ -44,6 +44,9 @@ export default function BufferHeader(props) {
switch (props.bouncerNetwork.state) {
case "disconnected":
description = "Bouncer disconnected from network";
if (props.bouncerNetwork.error) {
description += ": " + props.bouncerNetwork.error;
}
break;
case "connecting":
description = "Bouncer connecting to network...";
@@ -74,6 +77,12 @@ export default function BufferHeader(props) {
onClick=${props.onReconnect}
>Reconnect</button>
`;
let settingsButton = html`
<button
key="settings"
onClick="${props.onOpenSettings}"
>Settings</button>
`;
if (props.server.isBouncer) {
if (props.server.bouncerNetID) {
@@ -99,27 +108,16 @@ export default function BufferHeader(props) {
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
actions.push(settingsButton);
}
} else {
if (fullyConnected) {
actions.push(joinButton);
actions.push(settingsButton);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
actions.push(settingsButton);
}
break;
case BufferType.CHANNEL:
@@ -189,6 +187,10 @@ export default function BufferHeader(props) {
let desc = "This user is a server operator, they have administrator privileges.";
details.push(html`<abbr title=${desc}>server operator</abbr>`);
}
if (props.user.bot) {
let desc = "This user is an automated bot.";
details.push(html`<abbr title=${desc}>bot</abbr>`);
}
details = details.map((item, i) => {
if (i === 0) {
return item;

View File

@@ -44,9 +44,8 @@ export default function BufferList(props) {
let server = props.servers.get(buf.server);
let bouncerNetwork = null;
let bouncerNetID = server.bouncerNetID;
if (bouncerNetID) {
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
if (server.bouncerNetID) {
bouncerNetwork = props.bouncerNetworks.get(server.bouncerNetID);
}
return html`

View File

@@ -2,7 +2,7 @@ import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js";
import * as irc from "../lib/irc.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, ServerStatus, getNickURL, getChannelURL, getMessageURL } from "../state.js";
import { BufferType, ServerStatus, BufferEventsDisplayMode, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import * as store from "../store.js";
import Membership from "./membership.js";
@@ -27,15 +27,22 @@ function Nick(props) {
`;
}
function Timestamp({ date, url }) {
function _Timestamp({ date, url, showSeconds }) {
if (!date) {
return html`<spam class="timestamp">--:--:--</span>`;
let timestamp = "--:--";
if (showSeconds) {
timestamp += ":--";
}
return html`<spam class="timestamp">${timestamp}</span>`;
}
let hh = date.getHours().toString().padStart(2, "0");
let mm = date.getMinutes().toString().padStart(2, "0");
let ss = date.getSeconds().toString().padStart(2, "0");
let timestamp = `${hh}:${mm}:${ss}`;
let timestamp = `${hh}:${mm}`;
if (showSeconds) {
let ss = date.getSeconds().toString().padStart(2, "0");
timestamp += ":" + ss;
}
return html`
<a
href=${url}
@@ -48,6 +55,16 @@ function Timestamp({ date, url }) {
`;
}
function Timestamp(props) {
return html`
<${SettingsContext.Consumer}>
${(settings) => html`
<${_Timestamp} ...${props} showSeconds=${settings.secondsInTimestamps}/>
`}
</>
`;
}
/**
* Check whether a message can be folded.
*
@@ -85,12 +102,8 @@ class LogLine extends Component {
`;
}
function createChannel(channel) {
function onClick(event) {
event.preventDefault();
onChannelClick(channel);
}
return html`
<a href=${getChannelURL(channel)} onClick=${onClick}>
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
${channel}
</a>
`;
@@ -531,10 +544,7 @@ class DateSeparator extends Component {
render() {
let date = this.props.date;
let YYYY = date.getFullYear().toString().padStart(4, "0");
let MM = (date.getMonth() + 1).toString().padStart(2, "0");
let DD = date.getDate().toString().padStart(2, "0");
let text = `${YYYY}-${MM}-${DD}`;
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
return html`
<div class="separator date-separator">
${text}
@@ -553,7 +563,8 @@ function sameDate(d1, d2) {
export default class Buffer extends Component {
shouldComponentUpdate(nextProps) {
return this.props.buffer !== nextProps.buffer;
return this.props.buffer !== nextProps.buffer ||
this.props.settings !== nextProps.settings;
}
render() {
@@ -564,6 +575,7 @@ export default class Buffer extends Component {
let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let settings = this.props.settings;
let serverName = server.name;
let children = [];
@@ -640,7 +652,11 @@ export default class Buffer extends Component {
buf.messages.forEach((msg) => {
let sep = [];
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.prevReadReceipt && msg.tags.time > buf.prevReadReceipt.time) {
if (settings.bufferEvents === BufferEventsDisplayMode.HIDE && canFoldMessage(msg)) {
return;
}
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
sep.push(html`<${UnreadSeparator} key="unread"/>`);
hasUnreadSeparator = true;
}
@@ -658,7 +674,7 @@ export default class Buffer extends Component {
}
// TODO: consider checking the time difference too
if (canFoldMessage(msg)) {
if (settings.bufferEvents === BufferEventsDisplayMode.FOLD && canFoldMessage(msg)) {
foldMessages.push(msg);
return;
}

View File

@@ -143,7 +143,7 @@ export default class Composer extends Component {
return;
}
if (this.props.readOnly && event.key !== "/") {
if (this.props.readOnly || (this.props.commandOnly && event.key !== "/")) {
return;
}
@@ -167,7 +167,7 @@ export default class Composer extends Component {
return;
}
let text = event.clipboardData.getData('text');
let text = event.clipboardData.getData("text");
event.preventDefault();
event.stopImmediatePropagation();
@@ -201,6 +201,11 @@ export default class Composer extends Component {
className = "read-only";
}
let placeholder = "Type a message";
if (this.props.commandOnly) {
placeholder = "Type a command (see /help)";
}
return html`
<form
id="composer"
@@ -214,7 +219,7 @@ export default class Composer extends Component {
ref=${this.textInput}
value=${this.state.text}
autocomplete="off"
placeholder="Type a message"
placeholder=${placeholder}
enterkeyhint="send"
onKeyDown=${this.handleInputKeyDown}
/>

View File

@@ -138,7 +138,7 @@ export default class ConnectForm extends Component {
name="autojoin"
checked=${this.state.autojoin}
/>
Auto-join channel${s} <strong>${channels.join(', ')}</strong>
Auto-join channel${s} <strong>${channels.join(", ")}</strong>
</label>
<br/><br/>
`;
@@ -210,7 +210,7 @@ export default class ConnectForm extends Component {
<label>
Server password:<br/>
<input
type="text"
type="password"
name="pass"
value=${this.state.pass}
disabled=${disabled}

View File

@@ -26,27 +26,27 @@ function KeyBindingsHelp() {
`;
});
return html`
<dl>
<dt><kbd>/</kbd></dt>
<dd>Start writing a command</dd>
if (!window.matchMedia("(pointer: none)").matches) {
l.push(html`
<dt><strong>Middle mouse click</strong></dt>
<dd>Close buffer</dd>
`);
}
${l}
</dl>
`;
return html`<dl>${l}</dl>`;
}
function CommandsHelp() {
let l = Object.keys(commands).map((name) => {
let cmd = commands[name];
let usage = "/" + name;
let usage = [html`<strong>/${name}</strong>`];
if (cmd.usage) {
usage += " " + cmd.usage;
usage.push(" " + cmd.usage);
}
return html`
<dt><strong><code>${usage}</code></strong></dt>
<dt><code>${usage}</code></dt>
<dd>${cmd.description}</dd>
`;
});

View File

@@ -34,6 +34,9 @@ export default class ScrollManager extends Component {
restoreScrollPosition() {
let target = this.props.target.current;
if (!target.firstChild) {
return;
}
let stickToKey = store.get(this.props.scrollKey);
if (!stickToKey) {

112
components/settings-form.js Normal file
View File

@@ -0,0 +1,112 @@
import { html, Component } from "../lib/index.js";
export default class SettingsForm extends Component {
state = {};
constructor(props) {
super(props);
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
this.state.bufferEvents = props.settings.bufferEvents;
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }, () => {
this.props.onChange(this.state);
});
}
handleSubmit(event) {
event.preventDefault();
this.props.onClose();
}
registerProtocol() {
let url = window.location.origin + window.location.pathname + "?open=%s";
try {
navigator.registerProtocolHandler("irc", url);
navigator.registerProtocolHandler("ircs", url);
} catch (err) {
console.error("Failed to register protocol handler: ", err);
}
}
render() {
let protocolHandler = null;
if (this.props.showProtocolHandler) {
protocolHandler = html`
<div class="protocol-handler">
<div class="left">
Set gamja as your default IRC client for this browser.
IRC links will be automatically opened here.
</div>
<div class="right">
<button type="button" onClick=${() => this.registerProtocol()}>
Enable
</button>
</div>
</div>
<br/><br/>
`;
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label>
<input
type="checkbox"
name="secondsInTimestamps"
checked=${this.state.secondsInTimestamps}
/>
Show seconds in time indicator
</label>
<br/><br/>
<label>
<input
type="radio"
name="bufferEvents"
value="fold"
checked=${this.state.bufferEvents === "fold"}
/>
Show and fold chat events
</label>
<br/>
<label>
<input
type="radio"
name="bufferEvents"
value="expand"
checked=${this.state.bufferEvents === "expand"}
/>
Show and expand chat events
</label>
<br/>
<label>
<input
type="radio"
name="bufferEvents"
value="hide"
checked=${this.state.bufferEvents === "hide"}
/>
Hide chat events
</label>
<br/><br/>
${protocolHandler}
<button type="button" class="danger" onClick=${() => this.props.onDisconnect()}>
Disconnect
</button>
<button>
Close
</button>
</form>
`;
}
}

View File

@@ -1,4 +1,4 @@
import { ReceiptType, Unread, BufferType, SERVER_BUFFER } from "./state.js";
import { ReceiptType, Unread, BufferType, SERVER_BUFFER, receiptFromMessage } from "./state.js";
function getSiblingBuffer(buffers, bufID, delta) {
let bufList = Array.from(buffers.values());
@@ -19,22 +19,24 @@ export const keybindings = [
app.setState((state) => {
let buffers = new Map();
state.buffers.forEach((buf) => {
if (buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
app.setReceipt(buf.name, ReceiptType.READ, lastMsg);
}
buffers.set(buf.id, {
...buf,
unread: Unread.NONE,
prevReadReceipt: null,
});
let receipts = {};
if (buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
receipts[ReceiptType.READ] = receiptFromMessage(lastMsg);
}
let client = app.clients.get(buf.server);
app.bufferStore.put({
name: buf.name,
server: client.params,
unread: Unread.NONE,
receipts,
});
});
return { buffers };

42
lib/base64.js Normal file
View File

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

View File

@@ -1,4 +1,5 @@
import * as irc from "./irc.js";
import * as base64 from "./base64.js";
// Static list of capabilities that are always requested when supported by the
// server
@@ -19,10 +20,10 @@ const permanentCaps = [
"draft/account-registration",
"draft/chathistory",
"draft/event-playback",
"draft/extended-monitor",
"soju.im/bouncer-networks",
"soju.im/read",
];
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
@@ -122,6 +123,8 @@ export default class Client extends EventTarget {
saslPlain: null,
saslExternal: false,
bouncerNetwork: null,
ping: 0,
eventPlayback: true,
};
debug = false;
batches = new Map();
@@ -244,6 +247,7 @@ export default class Client extends EventTarget {
this.setStatus(Client.Status.REGISTERING);
this.reconnectBackoff.reset();
this.setPingInterval(this.params.ping);
this.nick = this.params.nick;
@@ -457,11 +461,11 @@ export default class Client extends EventTarget {
let initialResp = null;
switch (mechanism) {
case "PLAIN":
let respStr = btoa("\0" + params.username + "\0" + params.password);
let respStr = base64.encode("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [btoa("")] };
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
break;
default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
@@ -621,6 +625,9 @@ export default class Client extends EventTarget {
if (!this.params.bouncerNetwork) {
wantCaps.push("soju.im/bouncer-networks-notify");
}
if (this.params.eventPlayback) {
wantCaps.push("draft/event-playback");
}
let msg = this.caps.requestAvailable(wantCaps);
if (msg) {
@@ -802,10 +809,14 @@ export default class Client extends EventTarget {
});
}
join(channel) {
join(channel, password) {
let params = [channel];
if (password) {
params.push(password);
}
let msg = {
command: "JOIN",
params: [channel],
params: params,
};
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
@@ -879,7 +890,7 @@ export default class Client extends EventTarget {
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { more: messages.length >= max };
return { messages, more: messages.length >= max };
});
}
@@ -897,7 +908,7 @@ export default class Client extends EventTarget {
after.time = messages[messages.length - 1].tags.time;
return this.fetchHistoryBetween(target, after, before, limit);
}
return null;
return { messages };
});
}

View File

@@ -73,7 +73,6 @@ export const ERR_SASLALREADY = "907";
export const STD_MEMBERSHIPS = "~&@%+";
export const STD_CHANTYPES = "#&+!";
export const STD_CHANMODES = "beI,k,l,imnst";
const tagEscapeMap = {
";": "\\:",
@@ -259,7 +258,7 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
const alphaNum = (() => {
try {
return new RegExp(/^\p{L}$/, "u");
return new RegExp(/^[\p{L}0-9]$/, "u");
} catch (e) {
return new RegExp(/^[a-zA-Z0-9]$/, "u");
}
@@ -271,8 +270,6 @@ function isWordBoundary(ch) {
case "_":
case "|":
return false;
case "\u00A0":
return true;
default:
return !alphaNum.test(ch);
}
@@ -301,7 +298,7 @@ export function isHighlight(msg, nick, cm) {
if (i > 0) {
left = text[i - 1];
}
if (i < text.length) {
if (i + nick.length < text.length) {
right = text[i + nick.length];
}
if (isWordBoundary(left) && isWordBoundary(right)) {
@@ -464,6 +461,23 @@ export class Isupport {
bouncerNetID() {
return this.raw.get("BOUNCER_NETID");
}
chanModes() {
const stdChanModes = ["beI", "k", "l", "imnst"];
if (!this.raw.has("CHANMODES")) {
return stdChanModes;
}
let chanModes = this.raw.get("CHANMODES").split(",");
if (chanModes.length != 4) {
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
return stdChanModes;
}
return chanModes;
}
bot() {
return this.raw.get("BOT");
}
}
export const CaseMapping = {
@@ -672,11 +686,10 @@ export function getMessageLabel(msg) {
}
export function forEachChannelModeUpdate(msg, isupport, callback) {
let chanmodes = isupport.chanModes();
let [a, b, c, d] = isupport.chanModes();
let prefix = isupport.prefix();
let typeByMode = new Map();
let [a, b, c, d] = chanmodes.split(",");
Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
@@ -834,7 +847,6 @@ export class CapRegistry {
});
break;
case "ACK":
// TODO: handle `ACK -cap` to
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
if (cap.startsWith("-")) {

13607
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,5 +24,8 @@
"source": "index.html",
"publicUrl": "."
}
},
"engines": {
"node": ">=14.13.0"
}
}

View File

@@ -1,5 +1,6 @@
import * as irc from "./lib/irc.js";
import Client from "./lib/client.js";
import { createContext } from "./lib/index.js";
export const SERVER_BUFFER = "*";
@@ -34,6 +35,14 @@ export const ReceiptType = {
READ: "read",
};
export const BufferEventsDisplayMode = {
FOLD: "fold",
EXPAND: "expand",
HIDE: "hide",
};
export const SettingsContext = createContext("settings");
export function getNickURL(nick) {
return "irc:///" + encodeURIComponent(nick) + ",isuser";
}
@@ -85,6 +94,42 @@ export function getServerName(server, bouncerNetwork) {
}
}
export function receiptFromMessage(msg) {
// At this point all messages are supposed to have a time tag.
// App.addMessage ensures this is the case even if the server doesn't
// support server-time.
if (!msg.tags.time) {
throw new Error("Missing time message tag");
}
return { time: msg.tags.time };
}
export function isReceiptBefore(a, b) {
if (!b) {
return false;
}
if (!a) {
return true;
}
if (!a.time || !b.time) {
throw new Error("Missing receipt time");
}
return a.time <= b.time;
}
export function isMessageBeforeReceipt(msg, receipt) {
if (!receipt) {
return false;
}
if (!msg.tags.time) {
throw new Error("Missing time message tag");
}
if (!receipt.time) {
throw new Error("Missing receipt time");
}
return msg.tags.time <= receipt.time;
}
function updateState(state, updater) {
let updated;
if (typeof updater === "function") {
@@ -172,6 +217,11 @@ export const State = {
servers: new Map(),
buffers: new Map(),
activeBuffer: null,
bouncerNetworks: new Map(),
settings: {
secondsInTimestamps: true,
bufferEvents: BufferEventsDisplayMode.FOLD,
},
};
},
updateServer(state, id, updater) {
@@ -302,6 +352,19 @@ export const State = {
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
return [id, { buffers }];
},
storeBouncerNetwork(state, id, attrs) {
let bouncerNetworks = new Map(state.bouncerNetworks);
bouncerNetworks.set(id, {
...bouncerNetworks.get(id),
...attrs,
});
return { bouncerNetworks };
},
deleteBouncerNetwork(state, id) {
let bouncerNetworks = new Map(state.bouncerNetworks);
bouncerNetworks.delete(id);
return { bouncerNetworks };
},
handleMessage(state, msg, serverID, client) {
function updateServer(updater) {
return State.updateServer(state, serverID, updater);
@@ -404,6 +467,10 @@ export const State = {
if (who.flags !== undefined) {
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
who.operator = who.flags.indexOf("*") >= 0;
let botFlag = client.isupport.bot();
if (botFlag) {
who.bot = who.flags.indexOf(botFlag) >= 0;
}
delete who.flags;
}

View File

@@ -1,3 +1,5 @@
import { ReceiptType } from "./state.js";
const PREFIX = "gamja_";
class Item {
@@ -24,18 +26,18 @@ class Item {
export const autoconnect = new Item("autoconnect");
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
export const settings = new Item("settings");
const rawReceipts = new Item("receipts");
export const receipts = {
load() {
let v = rawReceipts.load();
return new Map(Object.entries(v || {}));
},
put(m) {
rawReceipts.put(Object.fromEntries(m));
},
};
function debounce(f, delay) {
let timeout = null;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
f(...args);
}, delay);
};
}
export class Buffer {
raw = new Item("buffers");
@@ -44,14 +46,24 @@ export class Buffer {
constructor() {
let obj = this.raw.load();
this.m = new Map(Object.entries(obj || {}));
let saveImmediately = this.save.bind(this);
this.save = debounce(saveImmediately, 500);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
saveImmediately();
}
});
}
key(buf) {
// TODO: use case-mapping here somehow
return JSON.stringify({
name: buf.name,
name: buf.name.toLowerCase(),
server: {
url: buf.server.url,
nick: buf.server.nick,
nick: buf.server.nick.toLowerCase(),
bouncerNetwork: buf.server.bouncerNetwork,
},
});
@@ -72,14 +84,39 @@ export class Buffer {
put(buf) {
let key = this.key(buf);
let prev = this.m.get(key);
if (prev && prev.unread === buf.unread) {
return;
let updated = !this.m.has(key);
let prev = this.m.get(key) || {};
let unread = prev.unread;
if (buf.unread !== undefined && buf.unread !== prev.unread) {
unread = buf.unread;
updated = true;
}
let receipts = { ...prev.receipts };
if (buf.receipts) {
Object.keys(buf.receipts).forEach((k) => {
// Use a not-equals comparison here so that no-op receipt
// changes are correctly handled
if (!receipts[k] || receipts[k].time < buf.receipts[k].time) {
receipts[k] = buf.receipts[k];
updated = true;
}
});
if (receipts[ReceiptType.DELIVERED] < receipts[ReceiptType.READ]) {
receipts[ReceiptType.DELIVERED] = receipts[ReceiptType.READ];
updated = true;
}
}
if (!updated) {
return false;
}
this.m.set(this.key(buf), {
name: buf.name,
unread: buf.unread,
unread,
receipts,
server: {
url: buf.server.url,
nick: buf.server.nick,
@@ -88,6 +125,7 @@ export class Buffer {
});
this.save();
return true;
}
delete(buf) {

View File

@@ -186,7 +186,7 @@ button.danger:hover {
grid-column: 2;
display: grid;
grid-template-rows: auto auto;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr auto;
}
@@ -202,6 +202,9 @@ button.danger:hover {
padding: 5px 10px;
grid-row: 2;
grid-column: 1;
max-height: 20vh;
overflow-y: auto;
word-break: break-word;
}
#buffer-header .actions {
@@ -530,6 +533,14 @@ details summary[role="button"] {
overflow: auto; /* hack to clear floating elements */
}
.dialog .protocol-handler {
display: flex;
flex-direction: row;
}
.dialog .protocol-handler .left {
flex-grow: 1;
}
kbd {
background-color: #f0f0f0;
border: 1px solid #bfbfbf;