Compare commits
69 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bcd9d3607 | ||
|
|
e4ebf5eb80 | ||
|
|
1428ec4d49 | ||
|
|
839e46360e | ||
|
|
d0064dd647 | ||
|
|
b9693d53ec | ||
|
|
f6ba40046f | ||
|
|
54453c5f44 | ||
|
|
fa80a56516 | ||
|
|
7cabb6f85b | ||
|
|
505a6fd5ab | ||
|
|
8e30806fec | ||
|
|
f0c398a10c | ||
|
|
baaf576d82 | ||
|
|
e3c2d85a94 | ||
|
|
576b9d51eb | ||
|
|
6b04cb1417 | ||
|
|
8507500d74 | ||
|
|
aaef4e1629 | ||
|
|
cdd2da90a9 | ||
|
|
4a981997f0 | ||
|
|
f45b51d981 | ||
|
|
73db1a888e | ||
|
|
3dc98ec797 | ||
|
|
e37d5f363b | ||
|
|
221b1b6356 | ||
|
|
86b1030b7a | ||
|
|
08578c9a21 | ||
|
|
26cc073f41 | ||
|
|
9e703698ca | ||
|
|
37d7f4a1c5 | ||
|
|
962c05c066 | ||
|
|
f2c9fd1d7f | ||
|
|
a3eec9a351 | ||
|
|
2ac7be6218 | ||
|
|
5f8cd976e6 | ||
|
|
fbc42b6dab | ||
|
|
dc398baa3b | ||
|
|
6a9a8e88f1 | ||
|
|
f47d93af8a | ||
|
|
fce0936c20 | ||
|
|
0636544c40 | ||
|
|
7c6f334dbf | ||
|
|
7ddd783150 | ||
|
|
bb42ff6a07 | ||
|
|
db0ef39c6b | ||
|
|
77f54080e7 | ||
|
|
065b3f21fc | ||
|
|
d2bcea8c86 | ||
|
|
3d81466788 | ||
|
|
f2923452c1 | ||
|
|
39c36e7a7b | ||
|
|
e91b044134 | ||
|
|
4cb3abfa72 | ||
|
|
0063a5a372 | ||
|
|
1142145c6d | ||
|
|
f465e24adf | ||
|
|
7f7a7c1aac | ||
|
|
e1bbe34ff2 | ||
|
|
fab42ba2ee | ||
|
|
9f93e200ed | ||
|
|
bd48f36ade | ||
|
|
393fd93253 | ||
|
|
a0f8f1f52f | ||
|
|
5d6de11a4c | ||
|
|
6692ed0035 | ||
|
|
5e34067d38 | ||
|
|
690845c2af | ||
|
|
0b59cf92b9 |
@@ -5,7 +5,7 @@ packages:
|
||||
sources:
|
||||
- https://git.sr.ht/~emersion/gamja
|
||||
secrets:
|
||||
- 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
|
||||
- 77c7956b-003e-44f7-bb5c-2944b2047654 # deploy SSH key
|
||||
tasks:
|
||||
- setup: |
|
||||
cd gamja
|
||||
@@ -16,4 +16,4 @@ tasks:
|
||||
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
|
||||
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
|
||||
--delete --exclude=config.json \
|
||||
. deploy@emersion.fr:/srv/http/gamja
|
||||
. deploy@sheeta.emersion.fr:/srv/http/gamja
|
||||
|
||||
44
commands.js
44
commands.js
@@ -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 });
|
||||
|
||||
@@ -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,17 @@ export default class App extends Component {
|
||||
return id;
|
||||
}
|
||||
|
||||
sendReadReceipt(client, storedBuffer) {
|
||||
if (!client.supportsReadMarker()) {
|
||||
return;
|
||||
}
|
||||
let readReceipt = storedBuffer.receipts[ReceiptType.READ];
|
||||
if (storedBuffer.name === "*" || !readReceipt) {
|
||||
return;
|
||||
}
|
||||
client.setReadMarker(storedBuffer.name, readReceipt.time);
|
||||
}
|
||||
|
||||
switchBuffer(id) {
|
||||
let buf;
|
||||
this.setState((state) => {
|
||||
@@ -399,87 +431,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 +499,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 +535,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 +557,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 +589,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 +637,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 +689,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) {
|
||||
@@ -710,7 +742,12 @@ export default class App extends Component {
|
||||
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
|
||||
target = SERVER_BUFFER;
|
||||
} else {
|
||||
target = msg.prefix.name;
|
||||
let context = msg.tags['+draft/channel-context'];
|
||||
if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) {
|
||||
target = context;
|
||||
} else {
|
||||
target = msg.prefix.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg.command === "NOTICE" && !State.getBuffer(this.state, { server: serverID, name: target })) {
|
||||
@@ -830,6 +867,8 @@ export default class App extends Component {
|
||||
case "CHATHISTORY":
|
||||
case "ACK":
|
||||
case "BOUNCER":
|
||||
case "MARKREAD":
|
||||
case "READ":
|
||||
// Ignore these
|
||||
return [];
|
||||
default:
|
||||
@@ -840,31 +879,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 +949,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 +968,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 +1018,56 @@ export default class App extends Component {
|
||||
this.autoOpenURL = null;
|
||||
}
|
||||
break;
|
||||
case "MARKREAD":
|
||||
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 +1080,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 +1219,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 +1226,13 @@ export default class App extends Component {
|
||||
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
|
||||
});
|
||||
client.monitor(target);
|
||||
|
||||
if (client.supportsReadMarker()) {
|
||||
client.fetchReadMarker(target);
|
||||
}
|
||||
}
|
||||
|
||||
open(target, serverID) {
|
||||
open(target, serverID, password) {
|
||||
if (!serverID) {
|
||||
serverID = State.getActiveServerID(this.state);
|
||||
}
|
||||
@@ -1132,7 +1242,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 +1317,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 +1328,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 +1532,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 +1697,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 +1775,7 @@ export default class App extends Component {
|
||||
onReconnect=${() => this.reconnect()}
|
||||
onAddNetwork=${this.handleAddNetworkClick}
|
||||
onManageNetwork=${() => this.handleManageNetworkClick(activeBuffer.server)}
|
||||
onOpenSettings=${this.handleOpenSettingsClick}
|
||||
/>
|
||||
</section>
|
||||
`;
|
||||
@@ -1745,6 +1884,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 +1911,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 +1953,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 +1968,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}
|
||||
</>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +108,7 @@ 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) {
|
||||
@@ -113,13 +116,7 @@ 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);
|
||||
}
|
||||
break;
|
||||
case BufferType.CHANNEL:
|
||||
@@ -189,6 +186,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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -133,6 +146,10 @@ class LogLine extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.tags["+draft/channel-context"]) {
|
||||
content = html`<em>(only visible to you)</em> ${content}`;
|
||||
}
|
||||
|
||||
if (msg.isHighlight) {
|
||||
lineClass += " highlight";
|
||||
}
|
||||
@@ -531,10 +548,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 +567,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 +579,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 +656,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 +678,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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -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
112
components/settings-form.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
42
lib/base64.js
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1004,4 +1015,32 @@ export default class Client extends EventTarget {
|
||||
return { message: msg.params[2] };
|
||||
});
|
||||
}
|
||||
|
||||
supportsReadMarker() {
|
||||
return this.caps.enabled.has("draft/read-marker") || this.caps.enabled.has("soju.im/read");
|
||||
}
|
||||
|
||||
_markReadCmd() {
|
||||
if (this.caps.enabled.has("draft/read-marker")) {
|
||||
return "MARKREAD";
|
||||
} else if (this.caps.enabled.has("soju.im/read")) {
|
||||
return "READ";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
fetchReadMarker(target) {
|
||||
this.send({
|
||||
command: this._markReadCmd(),
|
||||
params: [target],
|
||||
});
|
||||
}
|
||||
|
||||
setReadMarker(target, t) {
|
||||
this.send({
|
||||
command: this._markReadCmd(),
|
||||
params: [target, "timestamp="+t],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
28
lib/irc.js
28
lib/irc.js
@@ -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
13607
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,5 +24,8 @@
|
||||
"source": "index.html",
|
||||
"publicUrl": "."
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
67
state.js
67
state.js
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
72
store.js
72
store.js
@@ -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) {
|
||||
|
||||
15
style.css
15
style.css
@@ -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 {
|
||||
@@ -349,6 +352,8 @@ form input[type="url"],
|
||||
form input[type="email"] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -530,6 +535,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;
|
||||
|
||||
Reference in New Issue
Block a user