Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+8
-4
@@ -54,19 +54,23 @@ function markServerBufferUnread(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const join = {
|
const join = {
|
||||||
usage: "<name>",
|
usage: "<name> [password]",
|
||||||
description: "Join a channel",
|
description: "Join a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let channel = args[0];
|
let channel = args[0];
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
throw new Error("Missing channel name");
|
throw new Error("Missing channel name");
|
||||||
}
|
}
|
||||||
app.open(channel);
|
if (args.length > 1) {
|
||||||
|
app.open(channel, null, args[1]);
|
||||||
|
} else {
|
||||||
|
app.open(channel);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const kick = {
|
const kick = {
|
||||||
usage: "<nick>",
|
usage: "<nick> [comment]",
|
||||||
description: "Remove a user from the channel",
|
description: "Remove a user from the channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let nick = args[0];
|
let nick = args[0];
|
||||||
@@ -393,7 +397,7 @@ export default {
|
|||||||
execute: (app, args) => givemode(app, args, "+v"),
|
execute: (app, args) => givemode(app, args, "+v"),
|
||||||
},
|
},
|
||||||
"who": {
|
"who": {
|
||||||
usage: "[<mask> [o]]",
|
usage: "<mask>",
|
||||||
description: "Retrieve a list of users",
|
description: "Retrieve a list of users",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
getActiveClient(app).send({ command: "WHO", params: args });
|
getActiveClient(app).send({ command: "WHO", params: args });
|
||||||
|
|||||||
+231
-151
@@ -16,7 +16,7 @@ import ScrollManager from "./scroll-manager.js";
|
|||||||
import Dialog from "./dialog.js";
|
import Dialog from "./dialog.js";
|
||||||
import { html, Component, createRef } from "../lib/index.js";
|
import { html, Component, createRef } from "../lib/index.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.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, State, getServerName, receiptFromMessage, isReceiptBefore, isMessageBeforeReceipt } from "../state.js";
|
||||||
import commands from "../commands.js";
|
import commands from "../commands.js";
|
||||||
import { setup as setupKeybindings } from "../keybindings.js";
|
import { setup as setupKeybindings } from "../keybindings.js";
|
||||||
import * as store from "../store.js";
|
import * as store from "../store.js";
|
||||||
@@ -60,11 +60,11 @@ function isProduction() {
|
|||||||
function parseQueryString() {
|
function parseQueryString() {
|
||||||
let query = window.location.search.substring(1);
|
let query = window.location.search.substring(1);
|
||||||
let params = {};
|
let params = {};
|
||||||
query.split('&').forEach((s) => {
|
query.split("&").forEach((s) => {
|
||||||
if (!s) {
|
if (!s) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let pair = s.split('=');
|
let pair = s.split("=");
|
||||||
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
|
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
|
||||||
});
|
});
|
||||||
return params;
|
return params;
|
||||||
@@ -118,20 +118,9 @@ function fillConnectParams(params) {
|
|||||||
return 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) {
|
function showNotification(title, options) {
|
||||||
if (!window.Notification || Notification.permission !== "granted") {
|
if (!window.Notification || Notification.permission !== "granted") {
|
||||||
return new EventTarget();
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This can still fail due to:
|
// This can still fail due to:
|
||||||
@@ -140,10 +129,32 @@ function showNotification(title, options) {
|
|||||||
return new Notification(title, options);
|
return new Notification(title, options);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to show notification: ", 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;
|
let lastErrorID = 0;
|
||||||
|
|
||||||
export default class App extends Component {
|
export default class App extends Component {
|
||||||
@@ -159,8 +170,8 @@ export default class App extends Component {
|
|||||||
saslExternal: false,
|
saslExternal: false,
|
||||||
autoconnect: false,
|
autoconnect: false,
|
||||||
autojoin: [],
|
autojoin: [],
|
||||||
|
ping: 0,
|
||||||
},
|
},
|
||||||
bouncerNetworks: new Map(),
|
|
||||||
connectForm: true,
|
connectForm: true,
|
||||||
loading: true,
|
loading: true,
|
||||||
dialog: null,
|
dialog: null,
|
||||||
@@ -184,6 +195,7 @@ export default class App extends Component {
|
|||||||
* confirmation for security reasons.
|
* confirmation for security reasons.
|
||||||
*/
|
*/
|
||||||
autoOpenURL = null;
|
autoOpenURL = null;
|
||||||
|
messageNotifications = new Set();
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -209,9 +221,6 @@ export default class App extends Component {
|
|||||||
this.handleVerifyClick = this.handleVerifyClick.bind(this);
|
this.handleVerifyClick = this.handleVerifyClick.bind(this);
|
||||||
this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
|
this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
|
||||||
|
|
||||||
this.saveReceipts = debounce(this.saveReceipts.bind(this), 500);
|
|
||||||
|
|
||||||
this.receipts = store.receipts.load();
|
|
||||||
this.bufferStore = new store.Buffer();
|
this.bufferStore = new store.Buffer();
|
||||||
|
|
||||||
configPromise.then((config) => {
|
configPromise.then((config) => {
|
||||||
@@ -254,6 +263,9 @@ export default class App extends Component {
|
|||||||
if (config.server.auth === "external") {
|
if (config.server.auth === "external") {
|
||||||
connectParams.saslExternal = true;
|
connectParams.saslExternal = true;
|
||||||
}
|
}
|
||||||
|
if (typeof config.server.ping === "number") {
|
||||||
|
connectParams.ping = config.server.ping;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let autoconnect = store.autoconnect.load();
|
let autoconnect = store.autoconnect.load();
|
||||||
@@ -392,6 +404,20 @@ export default class App extends Component {
|
|||||||
return id;
|
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) {
|
switchBuffer(id) {
|
||||||
let buf;
|
let buf;
|
||||||
this.setState((state) => {
|
this.setState((state) => {
|
||||||
@@ -399,33 +425,45 @@ export default class App extends Component {
|
|||||||
if (!buf) {
|
if (!buf) {
|
||||||
return;
|
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) {
|
if (!buf) {
|
||||||
return;
|
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) {
|
if (this.buffer.current) {
|
||||||
this.buffer.current.focus();
|
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) {
|
if (buf.messages.length > 0) {
|
||||||
let lastMsg = buf.messages[buf.messages.length - 1];
|
let lastMsg = buf.messages[buf.messages.length - 1];
|
||||||
this.setReceipt(buf.name, ReceiptType.READ, lastMsg);
|
let stored = {
|
||||||
|
|
||||||
let client = this.clients.get(buf.server);
|
|
||||||
this.bufferStore.put({
|
|
||||||
name: buf.name,
|
name: buf.name,
|
||||||
server: client.params,
|
server: client.params,
|
||||||
unread: Unread.NONE,
|
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);
|
let server = this.state.servers.get(buf.server);
|
||||||
@@ -435,53 +473,6 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return last;
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(serverID, bufName, msg) {
|
addMessage(serverID, bufName, msg) {
|
||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
|
|
||||||
@@ -496,8 +487,12 @@ export default class App extends Component {
|
|||||||
msg.tags.time = irc.formatDate(new Date());
|
msg.tags.time = irc.formatDate(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
let isDelivered = this.hasReceipt(bufName, ReceiptType.DELIVERED, msg);
|
let stored = this.bufferStore.get({ name: bufName, server: client.params });
|
||||||
let isRead = this.hasReceipt(bufName, ReceiptType.READ, msg);
|
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
|
// TODO: messages coming from infinite scroll shouldn't trigger notifications
|
||||||
|
|
||||||
if (client.isMyNick(msg.prefix.name)) {
|
if (client.isMyNick(msg.prefix.name)) {
|
||||||
@@ -528,12 +523,19 @@ export default class App extends Component {
|
|||||||
let notif = showNotification(title, {
|
let notif = showNotification(title, {
|
||||||
body: stripANSI(text),
|
body: stripANSI(text),
|
||||||
requireInteraction: true,
|
requireInteraction: true,
|
||||||
tag: "msg," + msg.prefix.name + "," + bufName,
|
tag: "msg,server=" + serverID + ",from=" + msg.prefix.name + ",to=" + bufName,
|
||||||
});
|
data: { bufferName: bufName, message: msg },
|
||||||
notif.addEventListener("click", () => {
|
|
||||||
// TODO: scroll to message
|
|
||||||
this.switchBuffer({ server: serverID, name: bufName });
|
|
||||||
});
|
});
|
||||||
|
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])) {
|
if (msg.command === "INVITE" && client.isMyNick(msg.params[0])) {
|
||||||
@@ -543,21 +545,30 @@ export default class App extends Component {
|
|||||||
let notif = new Notification("Invitation to " + channel, {
|
let notif = new Notification("Invitation to " + channel, {
|
||||||
body: msg.prefix.name + " has invited you to " + channel,
|
body: msg.prefix.name + " has invited you to " + channel,
|
||||||
requireInteraction: true,
|
requireInteraction: true,
|
||||||
tag: "invite," + msg.prefix.name + "," + channel,
|
tag: "invite,server=" + serverID + ",from=" + msg.prefix.name + ",channel=" + channel,
|
||||||
actions: [{
|
actions: [{
|
||||||
action: "accept",
|
action: "accept",
|
||||||
title: "Accept",
|
title: "Accept",
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
notif.addEventListener("click", (event) => {
|
if (notif) {
|
||||||
if (event.action === "accept") {
|
notif.addEventListener("click", (event) => {
|
||||||
this.setReceipt(bufName, ReceiptType.READ, msg);
|
if (event.action === "accept") {
|
||||||
this.open(channel, serverID);
|
let stored = {
|
||||||
} else {
|
name: bufName,
|
||||||
// TODO: scroll to message
|
server: client.params,
|
||||||
this.switchBuffer({ server: serverID, name: bufName });
|
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
|
// Open a new buffer if the message doesn't come from me or is a
|
||||||
@@ -566,31 +577,34 @@ export default class App extends Component {
|
|||||||
this.createBuffer(serverID, bufName);
|
this.createBuffer(serverID, bufName);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setReceipt(bufName, ReceiptType.DELIVERED, msg);
|
|
||||||
|
|
||||||
let bufID = { server: serverID, name: bufName };
|
let bufID = { server: serverID, name: bufName };
|
||||||
this.setState((state) => State.addMessage(state, msg, bufID));
|
this.setState((state) => State.addMessage(state, msg, bufID));
|
||||||
this.setBufferState(bufID, (buf) => {
|
this.setBufferState(bufID, (buf) => {
|
||||||
// TODO: set unread if scrolled up
|
// TODO: set unread if scrolled up
|
||||||
let unread = buf.unread;
|
let unread = buf.unread;
|
||||||
let prevReadReceipt = buf.prevReadReceipt;
|
let prevReadReceipt = buf.prevReadReceipt;
|
||||||
|
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
|
||||||
|
|
||||||
if (this.state.activeBuffer !== buf.id) {
|
if (this.state.activeBuffer !== buf.id) {
|
||||||
unread = Unread.union(unread, msgUnread);
|
unread = Unread.union(unread, msgUnread);
|
||||||
} else {
|
} else {
|
||||||
this.setReceipt(bufName, ReceiptType.READ, msg);
|
receipts[ReceiptType.READ] = receiptFromMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't show unread marker for my own messages
|
// Don't show unread marker for my own messages
|
||||||
if (client.isMyNick(msg.prefix.name)) {
|
if (client.isMyNick(msg.prefix.name) && !isMessageBeforeReceipt(msg, prevReadReceipt)) {
|
||||||
prevReadReceipt = { time: msg.tags.time };
|
prevReadReceipt = receiptFromMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.bufferStore.put({
|
let stored = {
|
||||||
name: buf.name,
|
name: buf.name,
|
||||||
server: client.params,
|
server: client.params,
|
||||||
unread,
|
unread,
|
||||||
});
|
receipts,
|
||||||
|
};
|
||||||
|
if (this.bufferStore.put(stored)) {
|
||||||
|
this.sendReadReceipt(client, stored);
|
||||||
|
}
|
||||||
return { unread, prevReadReceipt };
|
return { unread, prevReadReceipt };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -653,10 +667,6 @@ export default class App extends Component {
|
|||||||
if (params.autojoin.length > 0) {
|
if (params.autojoin.length > 0) {
|
||||||
this.switchToChannel = params.autojoin[0];
|
this.switchToChannel = params.autojoin[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.server && typeof this.config.server.ping !== "undefined") {
|
|
||||||
client.setPingInterval(this.config.server.ping);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(serverID) {
|
disconnect(serverID) {
|
||||||
@@ -830,6 +840,7 @@ export default class App extends Component {
|
|||||||
case "CHATHISTORY":
|
case "CHATHISTORY":
|
||||||
case "ACK":
|
case "ACK":
|
||||||
case "BOUNCER":
|
case "BOUNCER":
|
||||||
|
case "READ":
|
||||||
// Ignore these
|
// Ignore these
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
@@ -854,17 +865,7 @@ export default class App extends Component {
|
|||||||
let target, channel;
|
let target, channel;
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case irc.RPL_WELCOME:
|
case irc.RPL_WELCOME:
|
||||||
let lastReceipt = this.latestReceipt(ReceiptType.DELIVERED);
|
this.fetchBacklog(serverID);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case irc.RPL_ENDOFMOTD:
|
case irc.RPL_ENDOFMOTD:
|
||||||
case irc.ERR_NOMOTD:
|
case irc.ERR_NOMOTD:
|
||||||
@@ -923,14 +924,6 @@ export default class App extends Component {
|
|||||||
this.switchToChannel = null;
|
this.switchToChannel = null;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "PART":
|
|
||||||
channel = msg.params[0];
|
|
||||||
|
|
||||||
if (client.isMyNick(msg.prefix.name)) {
|
|
||||||
this.receipts.delete(channel);
|
|
||||||
this.saveReceipts();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "BOUNCER":
|
case "BOUNCER":
|
||||||
if (msg.params[0] !== "NETWORK") {
|
if (msg.params[0] !== "NETWORK") {
|
||||||
break; // We're only interested in network updates
|
break; // We're only interested in network updates
|
||||||
@@ -950,16 +943,12 @@ export default class App extends Component {
|
|||||||
|
|
||||||
let isNew = false;
|
let isNew = false;
|
||||||
this.setState((state) => {
|
this.setState((state) => {
|
||||||
let bouncerNetworks = new Map(state.bouncerNetworks);
|
|
||||||
if (!attrs) {
|
if (!attrs) {
|
||||||
bouncerNetworks.delete(id);
|
return State.deleteBouncerNetwork(state, id);
|
||||||
} else {
|
} else {
|
||||||
let prev = bouncerNetworks.get(id);
|
isNew = !state.bouncerNetworks.has(id);
|
||||||
isNew = prev === undefined;
|
return State.storeBouncerNetwork(state, id, attrs);
|
||||||
attrs = { ...prev, ...attrs };
|
|
||||||
bouncerNetworks.set(id, attrs);
|
|
||||||
}
|
}
|
||||||
return { bouncerNetworks };
|
|
||||||
}, () => {
|
}, () => {
|
||||||
if (!attrs) {
|
if (!attrs) {
|
||||||
let serverID = this.serverFromBouncerNetwork(id);
|
let serverID = this.serverFromBouncerNetwork(id);
|
||||||
@@ -1004,6 +993,55 @@ export default class App extends Component {
|
|||||||
this.autoOpenURL = null;
|
this.autoOpenURL = null;
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
||||||
let description = msg.params[msg.params.length - 1];
|
let description = msg.params[msg.params.length - 1];
|
||||||
@@ -1016,6 +1054,50 @@ 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).catch((err) => {
|
||||||
|
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
|
||||||
|
this.showError("Failed to fetch backlog for '" + target.name + "'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleConnectSubmit(connectParams) {
|
handleConnectSubmit(connectParams) {
|
||||||
this.dismissError();
|
this.dismissError();
|
||||||
|
|
||||||
@@ -1104,15 +1186,6 @@ export default class App extends Component {
|
|||||||
this.open(nick);
|
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) {
|
whoUserBuffer(target, serverID) {
|
||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
|
|
||||||
@@ -1120,9 +1193,16 @@ export default class App extends Component {
|
|||||||
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
|
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
|
||||||
});
|
});
|
||||||
client.monitor(target);
|
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) {
|
if (!serverID) {
|
||||||
serverID = State.getActiveServerID(this.state);
|
serverID = State.getActiveServerID(this.state);
|
||||||
}
|
}
|
||||||
@@ -1132,7 +1212,7 @@ export default class App extends Component {
|
|||||||
this.switchBuffer({ server: serverID });
|
this.switchBuffer({ server: serverID });
|
||||||
} else if (client.isChannel(target)) {
|
} else if (client.isChannel(target)) {
|
||||||
this.switchToChannel = target;
|
this.switchToChannel = target;
|
||||||
client.join(target).catch((err) => {
|
client.join(target, password).catch((err) => {
|
||||||
this.showError(err);
|
this.showError(err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -1216,9 +1296,6 @@ export default class App extends Component {
|
|||||||
|
|
||||||
client.unmonitor(buf.name);
|
client.unmonitor(buf.name);
|
||||||
|
|
||||||
this.receipts.delete(buf.name);
|
|
||||||
this.saveReceipts();
|
|
||||||
|
|
||||||
this.bufferStore.delete({ name: buf.name, server: client.params });
|
this.bufferStore.delete({ name: buf.name, server: client.params });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1759,13 +1836,15 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let composerReadOnly = false;
|
let composerReadOnly = false;
|
||||||
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
|
|
||||||
composerReadOnly = true;
|
|
||||||
}
|
|
||||||
if (activeServer && activeServer.status !== ServerStatus.REGISTERED) {
|
if (activeServer && activeServer.status !== ServerStatus.REGISTERED) {
|
||||||
composerReadOnly = true;
|
composerReadOnly = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let commandOnly = false;
|
||||||
|
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
|
||||||
|
commandOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section
|
<section
|
||||||
id="buffer-list"
|
id="buffer-list"
|
||||||
@@ -1813,6 +1892,7 @@ export default class App extends Component {
|
|||||||
readOnly=${composerReadOnly}
|
readOnly=${composerReadOnly}
|
||||||
onSubmit=${this.handleComposerSubmit}
|
onSubmit=${this.handleComposerSubmit}
|
||||||
autocomplete=${this.autocomplete}
|
autocomplete=${this.autocomplete}
|
||||||
|
commandOnly=${commandOnly}
|
||||||
/>
|
/>
|
||||||
${dialog}
|
${dialog}
|
||||||
${error}
|
${error}
|
||||||
|
|||||||
@@ -44,9 +44,8 @@ export default function BufferList(props) {
|
|||||||
let server = props.servers.get(buf.server);
|
let server = props.servers.get(buf.server);
|
||||||
|
|
||||||
let bouncerNetwork = null;
|
let bouncerNetwork = null;
|
||||||
let bouncerNetID = server.bouncerNetID;
|
if (server.bouncerNetID) {
|
||||||
if (bouncerNetID) {
|
bouncerNetwork = props.bouncerNetworks.get(server.bouncerNetID);
|
||||||
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
+4
-11
@@ -2,7 +2,7 @@ import { html, Component } from "../lib/index.js";
|
|||||||
import linkify from "../lib/linkify.js";
|
import linkify from "../lib/linkify.js";
|
||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import { BufferType, ServerStatus, getNickURL, getChannelURL, getMessageURL } from "../state.js";
|
import { BufferType, ServerStatus, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt } from "../state.js";
|
||||||
import * as store from "../store.js";
|
import * as store from "../store.js";
|
||||||
import Membership from "./membership.js";
|
import Membership from "./membership.js";
|
||||||
|
|
||||||
@@ -85,12 +85,8 @@ class LogLine extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
function createChannel(channel) {
|
function createChannel(channel) {
|
||||||
function onClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
onChannelClick(channel);
|
|
||||||
}
|
|
||||||
return html`
|
return html`
|
||||||
<a href=${getChannelURL(channel)} onClick=${onClick}>
|
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
|
||||||
${channel}
|
${channel}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
@@ -531,10 +527,7 @@ class DateSeparator extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
let date = this.props.date;
|
let date = this.props.date;
|
||||||
let YYYY = date.getFullYear().toString().padStart(4, "0");
|
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||||
let MM = (date.getMonth() + 1).toString().padStart(2, "0");
|
|
||||||
let DD = date.getDate().toString().padStart(2, "0");
|
|
||||||
let text = `${YYYY}-${MM}-${DD}`;
|
|
||||||
return html`
|
return html`
|
||||||
<div class="separator date-separator">
|
<div class="separator date-separator">
|
||||||
${text}
|
${text}
|
||||||
@@ -640,7 +633,7 @@ export default class Buffer extends Component {
|
|||||||
buf.messages.forEach((msg) => {
|
buf.messages.forEach((msg) => {
|
||||||
let sep = [];
|
let sep = [];
|
||||||
|
|
||||||
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.prevReadReceipt && msg.tags.time > buf.prevReadReceipt.time) {
|
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
|
||||||
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
||||||
hasUnreadSeparator = true;
|
hasUnreadSeparator = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default class Composer extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.readOnly && event.key !== "/") {
|
if (this.props.readOnly || (this.props.commandOnly && event.key !== "/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export default class Composer extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = event.clipboardData.getData('text');
|
let text = event.clipboardData.getData("text");
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
@@ -201,6 +201,11 @@ export default class Composer extends Component {
|
|||||||
className = "read-only";
|
className = "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let placeholder = "Type a message";
|
||||||
|
if (this.props.commandOnly) {
|
||||||
|
placeholder = "Type a command (see /help)";
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form
|
<form
|
||||||
id="composer"
|
id="composer"
|
||||||
@@ -214,7 +219,7 @@ export default class Composer extends Component {
|
|||||||
ref=${this.textInput}
|
ref=${this.textInput}
|
||||||
value=${this.state.text}
|
value=${this.state.text}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder="Type a message"
|
placeholder=${placeholder}
|
||||||
enterkeyhint="send"
|
enterkeyhint="send"
|
||||||
onKeyDown=${this.handleInputKeyDown}
|
onKeyDown=${this.handleInputKeyDown}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default class ConnectForm extends Component {
|
|||||||
name="autojoin"
|
name="autojoin"
|
||||||
checked=${this.state.autojoin}
|
checked=${this.state.autojoin}
|
||||||
/>
|
/>
|
||||||
Auto-join channel${s} <strong>${channels.join(', ')}</strong>
|
Auto-join channel${s} <strong>${channels.join(", ")}</strong>
|
||||||
</label>
|
</label>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
`;
|
`;
|
||||||
@@ -210,7 +210,7 @@ export default class ConnectForm extends Component {
|
|||||||
<label>
|
<label>
|
||||||
Server password:<br/>
|
Server password:<br/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="password"
|
||||||
name="pass"
|
name="pass"
|
||||||
value=${this.state.pass}
|
value=${this.state.pass}
|
||||||
disabled=${disabled}
|
disabled=${disabled}
|
||||||
|
|||||||
+10
-10
@@ -26,27 +26,27 @@ function KeyBindingsHelp() {
|
|||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return html`
|
if (!window.matchMedia("(pointer: none)").matches) {
|
||||||
<dl>
|
l.push(html`
|
||||||
<dt><kbd>/</kbd></dt>
|
<dt><strong>Middle mouse click</strong></dt>
|
||||||
<dd>Start writing a command</dd>
|
<dd>Close buffer</dd>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
${l}
|
return html`<dl>${l}</dl>`;
|
||||||
</dl>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandsHelp() {
|
function CommandsHelp() {
|
||||||
let l = Object.keys(commands).map((name) => {
|
let l = Object.keys(commands).map((name) => {
|
||||||
let cmd = commands[name];
|
let cmd = commands[name];
|
||||||
|
|
||||||
let usage = "/" + name;
|
let usage = [html`<strong>/${name}</strong>`];
|
||||||
if (cmd.usage) {
|
if (cmd.usage) {
|
||||||
usage += " " + cmd.usage;
|
usage.push(" " + cmd.usage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dt><strong><code>${usage}</code></strong></dt>
|
<dt><code>${usage}</code></dt>
|
||||||
<dd>${cmd.description}</dd>
|
<dd>${cmd.description}</dd>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-6
@@ -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) {
|
function getSiblingBuffer(buffers, bufID, delta) {
|
||||||
let bufList = Array.from(buffers.values());
|
let bufList = Array.from(buffers.values());
|
||||||
@@ -19,22 +19,24 @@ export const keybindings = [
|
|||||||
app.setState((state) => {
|
app.setState((state) => {
|
||||||
let buffers = new Map();
|
let buffers = new Map();
|
||||||
state.buffers.forEach((buf) => {
|
state.buffers.forEach((buf) => {
|
||||||
if (buf.messages.length > 0) {
|
|
||||||
let lastMsg = buf.messages[buf.messages.length - 1];
|
|
||||||
app.setReceipt(buf.name, ReceiptType.READ, lastMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
buffers.set(buf.id, {
|
buffers.set(buf.id, {
|
||||||
...buf,
|
...buf,
|
||||||
unread: Unread.NONE,
|
unread: Unread.NONE,
|
||||||
prevReadReceipt: null,
|
prevReadReceipt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let receipts = {};
|
||||||
|
if (buf.messages.length > 0) {
|
||||||
|
let lastMsg = buf.messages[buf.messages.length - 1];
|
||||||
|
receipts[ReceiptType.READ] = receiptFromMessage(lastMsg);
|
||||||
|
}
|
||||||
|
|
||||||
let client = app.clients.get(buf.server);
|
let client = app.clients.get(buf.server);
|
||||||
app.bufferStore.put({
|
app.bufferStore.put({
|
||||||
name: buf.name,
|
name: buf.name,
|
||||||
server: client.params,
|
server: client.params,
|
||||||
unread: Unread.NONE,
|
unread: Unread.NONE,
|
||||||
|
receipts,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { buffers };
|
return { buffers };
|
||||||
|
|||||||
+9
-2
@@ -23,6 +23,7 @@ const permanentCaps = [
|
|||||||
"draft/extended-monitor",
|
"draft/extended-monitor",
|
||||||
|
|
||||||
"soju.im/bouncer-networks",
|
"soju.im/bouncer-networks",
|
||||||
|
"soju.im/read",
|
||||||
];
|
];
|
||||||
|
|
||||||
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
|
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
|
||||||
@@ -122,6 +123,7 @@ export default class Client extends EventTarget {
|
|||||||
saslPlain: null,
|
saslPlain: null,
|
||||||
saslExternal: false,
|
saslExternal: false,
|
||||||
bouncerNetwork: null,
|
bouncerNetwork: null,
|
||||||
|
ping: 0,
|
||||||
};
|
};
|
||||||
debug = false;
|
debug = false;
|
||||||
batches = new Map();
|
batches = new Map();
|
||||||
@@ -244,6 +246,7 @@ export default class Client extends EventTarget {
|
|||||||
this.setStatus(Client.Status.REGISTERING);
|
this.setStatus(Client.Status.REGISTERING);
|
||||||
|
|
||||||
this.reconnectBackoff.reset();
|
this.reconnectBackoff.reset();
|
||||||
|
this.setPingInterval(this.params.ping);
|
||||||
|
|
||||||
this.nick = this.params.nick;
|
this.nick = this.params.nick;
|
||||||
|
|
||||||
@@ -802,10 +805,14 @@ export default class Client extends EventTarget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
join(channel) {
|
join(channel, password) {
|
||||||
|
let params = [channel];
|
||||||
|
if (password) {
|
||||||
|
params.push(password);
|
||||||
|
}
|
||||||
let msg = {
|
let msg = {
|
||||||
command: "JOIN",
|
command: "JOIN",
|
||||||
params: [channel],
|
params: params,
|
||||||
};
|
};
|
||||||
return this.roundtrip(msg, (msg) => {
|
return this.roundtrip(msg, (msg) => {
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
|
|||||||
+16
-8
@@ -73,7 +73,6 @@ export const ERR_SASLALREADY = "907";
|
|||||||
|
|
||||||
export const STD_MEMBERSHIPS = "~&@%+";
|
export const STD_MEMBERSHIPS = "~&@%+";
|
||||||
export const STD_CHANTYPES = "#&+!";
|
export const STD_CHANTYPES = "#&+!";
|
||||||
export const STD_CHANMODES = "beI,k,l,imnst";
|
|
||||||
|
|
||||||
const tagEscapeMap = {
|
const tagEscapeMap = {
|
||||||
";": "\\:",
|
";": "\\:",
|
||||||
@@ -259,7 +258,7 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
|
|||||||
|
|
||||||
const alphaNum = (() => {
|
const alphaNum = (() => {
|
||||||
try {
|
try {
|
||||||
return new RegExp(/^\p{L}$/, "u");
|
return new RegExp(/^[\p{L}0-9]$/, "u");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return new RegExp(/^[a-zA-Z0-9]$/, "u");
|
return new RegExp(/^[a-zA-Z0-9]$/, "u");
|
||||||
}
|
}
|
||||||
@@ -271,8 +270,6 @@ function isWordBoundary(ch) {
|
|||||||
case "_":
|
case "_":
|
||||||
case "|":
|
case "|":
|
||||||
return false;
|
return false;
|
||||||
case "\u00A0":
|
|
||||||
return true;
|
|
||||||
default:
|
default:
|
||||||
return !alphaNum.test(ch);
|
return !alphaNum.test(ch);
|
||||||
}
|
}
|
||||||
@@ -301,7 +298,7 @@ export function isHighlight(msg, nick, cm) {
|
|||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
left = text[i - 1];
|
left = text[i - 1];
|
||||||
}
|
}
|
||||||
if (i < text.length) {
|
if (i + nick.length < text.length) {
|
||||||
right = text[i + nick.length];
|
right = text[i + nick.length];
|
||||||
}
|
}
|
||||||
if (isWordBoundary(left) && isWordBoundary(right)) {
|
if (isWordBoundary(left) && isWordBoundary(right)) {
|
||||||
@@ -464,6 +461,19 @@ export class Isupport {
|
|||||||
bouncerNetID() {
|
bouncerNetID() {
|
||||||
return this.raw.get("BOUNCER_NETID");
|
return this.raw.get("BOUNCER_NETID");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chanModes() {
|
||||||
|
const stdChanModes = ["beI", "k", "l", "imnst"];
|
||||||
|
if (!this.raw.has("CHANMODES")) {
|
||||||
|
return stdChanModes;
|
||||||
|
}
|
||||||
|
let chanModes = this.raw.get("CHANMODES").split(",");
|
||||||
|
if (chanModes.length != 4) {
|
||||||
|
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
|
||||||
|
return stdChanModes;
|
||||||
|
}
|
||||||
|
return chanModes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CaseMapping = {
|
export const CaseMapping = {
|
||||||
@@ -672,11 +682,10 @@ export function getMessageLabel(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function forEachChannelModeUpdate(msg, isupport, callback) {
|
export function forEachChannelModeUpdate(msg, isupport, callback) {
|
||||||
let chanmodes = isupport.chanModes();
|
let [a, b, c, d] = isupport.chanModes();
|
||||||
let prefix = isupport.prefix();
|
let prefix = isupport.prefix();
|
||||||
|
|
||||||
let typeByMode = new Map();
|
let typeByMode = new Map();
|
||||||
let [a, b, c, d] = chanmodes.split(",");
|
|
||||||
Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
|
Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
|
||||||
Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
|
Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
|
||||||
Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
|
Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
|
||||||
@@ -834,7 +843,6 @@ export class CapRegistry {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "ACK":
|
case "ACK":
|
||||||
// TODO: handle `ACK -cap` to
|
|
||||||
args[0].split(" ").forEach((cap) => {
|
args[0].split(" ").forEach((cap) => {
|
||||||
cap = cap.toLowerCase();
|
cap = cap.toLowerCase();
|
||||||
if (cap.startsWith("-")) {
|
if (cap.startsWith("-")) {
|
||||||
|
|||||||
Generated
+3
@@ -17,6 +17,9 @@
|
|||||||
"parcel": "^2.0.0",
|
"parcel": "^2.0.0",
|
||||||
"split": "^1.0.1",
|
"split": "^1.0.1",
|
||||||
"ws": "^8.3.0"
|
"ws": "^8.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|||||||
@@ -24,5 +24,8 @@
|
|||||||
"source": "index.html",
|
"source": "index.html",
|
||||||
"publicUrl": "."
|
"publicUrl": "."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,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) {
|
function updateState(state, updater) {
|
||||||
let updated;
|
let updated;
|
||||||
if (typeof updater === "function") {
|
if (typeof updater === "function") {
|
||||||
@@ -172,6 +208,7 @@ export const State = {
|
|||||||
servers: new Map(),
|
servers: new Map(),
|
||||||
buffers: new Map(),
|
buffers: new Map(),
|
||||||
activeBuffer: null,
|
activeBuffer: null,
|
||||||
|
bouncerNetworks: new Map(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
updateServer(state, id, updater) {
|
updateServer(state, id, updater) {
|
||||||
@@ -302,6 +339,19 @@ export const State = {
|
|||||||
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
|
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
|
||||||
return [id, { buffers }];
|
return [id, { buffers }];
|
||||||
},
|
},
|
||||||
|
storeBouncerNetwork(state, id, attrs) {
|
||||||
|
let bouncerNetworks = new Map(state.bouncerNetworks);
|
||||||
|
bouncerNetworks.set(id, {
|
||||||
|
...bouncerNetworks.get(id),
|
||||||
|
...attrs,
|
||||||
|
});
|
||||||
|
return { bouncerNetworks };
|
||||||
|
},
|
||||||
|
deleteBouncerNetwork(state, id) {
|
||||||
|
let bouncerNetworks = new Map(state.bouncerNetworks);
|
||||||
|
bouncerNetworks.delete(id);
|
||||||
|
return { bouncerNetworks };
|
||||||
|
},
|
||||||
handleMessage(state, msg, serverID, client) {
|
handleMessage(state, msg, serverID, client) {
|
||||||
function updateServer(updater) {
|
function updateServer(updater) {
|
||||||
return State.updateServer(state, serverID, updater);
|
return State.updateServer(state, serverID, updater);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ReceiptType } from "./state.js";
|
||||||
|
|
||||||
const PREFIX = "gamja_";
|
const PREFIX = "gamja_";
|
||||||
|
|
||||||
class Item {
|
class Item {
|
||||||
@@ -25,17 +27,16 @@ class Item {
|
|||||||
export const autoconnect = new Item("autoconnect");
|
export const autoconnect = new Item("autoconnect");
|
||||||
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
|
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
|
||||||
|
|
||||||
const rawReceipts = new Item("receipts");
|
function debounce(f, delay) {
|
||||||
|
let timeout = null;
|
||||||
export const receipts = {
|
return (...args) => {
|
||||||
load() {
|
clearTimeout(timeout);
|
||||||
let v = rawReceipts.load();
|
timeout = setTimeout(() => {
|
||||||
return new Map(Object.entries(v || {}));
|
timeout = null;
|
||||||
},
|
f(...args);
|
||||||
put(m) {
|
}, delay);
|
||||||
rawReceipts.put(Object.fromEntries(m));
|
};
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export class Buffer {
|
export class Buffer {
|
||||||
raw = new Item("buffers");
|
raw = new Item("buffers");
|
||||||
@@ -44,6 +45,15 @@ export class Buffer {
|
|||||||
constructor() {
|
constructor() {
|
||||||
let obj = this.raw.load();
|
let obj = this.raw.load();
|
||||||
this.m = new Map(Object.entries(obj || {}));
|
this.m = new Map(Object.entries(obj || {}));
|
||||||
|
|
||||||
|
let saveImmediately = this.save.bind(this);
|
||||||
|
this.save = debounce(saveImmediately, 500);
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
saveImmediately();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
key(buf) {
|
key(buf) {
|
||||||
@@ -72,14 +82,39 @@ export class Buffer {
|
|||||||
put(buf) {
|
put(buf) {
|
||||||
let key = this.key(buf);
|
let key = this.key(buf);
|
||||||
|
|
||||||
let prev = this.m.get(key);
|
let updated = !this.m.has(key);
|
||||||
if (prev && prev.unread === buf.unread) {
|
let prev = this.m.get(key) || {};
|
||||||
return;
|
|
||||||
|
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), {
|
this.m.set(this.key(buf), {
|
||||||
name: buf.name,
|
name: buf.name,
|
||||||
unread: buf.unread,
|
unread,
|
||||||
|
receipts,
|
||||||
server: {
|
server: {
|
||||||
url: buf.server.url,
|
url: buf.server.url,
|
||||||
nick: buf.server.nick,
|
nick: buf.server.nick,
|
||||||
@@ -88,6 +123,7 @@ export class Buffer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(buf) {
|
delete(buf) {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ button.danger:hover {
|
|||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: 1fr auto;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +202,9 @@ button.danger:hover {
|
|||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
|
max-height: 20vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
#buffer-header .actions {
|
#buffer-header .actions {
|
||||||
|
|||||||
Reference in New Issue
Block a user