gamja/components/app.js

2260 lines
58 KiB
JavaScript
Raw Normal View History

import * as irc from "../lib/irc.js";
import Client from "../lib/client.js";
import * as oauth2 from "../lib/oauth2.js";
import Buffer from "./buffer.js";
import BufferList from "./buffer-list.js";
import BufferHeader from "./buffer-header.js";
import MemberList from "./member-list.js";
import ConnectForm from "./connect-form.js";
import JoinForm from "./join-form.js";
import Help from "./help.js";
2021-03-08 12:15:04 -05:00
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";
2023-06-08 09:07:28 -04:00
import SwitcherForm from "./switcher-form.js";
import Composer from "./composer.js";
import ScrollManager from "./scroll-manager.js";
2021-03-08 10:23:16 -05:00
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, 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";
2020-06-26 04:35:38 -04:00
const baseConfig = {
server: {},
};
const configPromise = fetch("./config.json")
.then((resp) => {
if (resp.ok) {
return resp.json();
}
if (resp.status !== 404) {
console.error("Failed to fetch config: HTTP error:", resp.status, resp.statusText);
}
return {};
})
.catch((err) => {
console.error("Failed to fetch config:", err);
return {};
})
.then((config) => {
return {
...baseConfig,
...config,
};
});
2020-07-15 12:21:09 -04:00
const CHATHISTORY_MAX_SIZE = 4000;
function isProduction() {
// NODE_ENV is set by the Parcel build system
try {
return process.env.NODE_ENV === "production";
} catch (err) {
return false;
}
}
function parseQueryString() {
2021-06-10 12:11:11 -04:00
let query = window.location.search.substring(1);
let params = {};
query.split("&").forEach((s) => {
if (!s) {
return;
}
let pair = s.split("=");
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
});
return params;
}
2021-11-16 05:52:38 -05:00
function splitHostPort(str) {
let host = str;
let port = null;
// Literal IPv6 addresses contain colons and are enclosed in square brackets
let i = str.lastIndexOf(":");
if (i > 0 && !str.endsWith("]")) {
host = str.slice(0, i);
port = parseInt(str.slice(i + 1), 10);
}
if (host.startsWith("[") && host.endsWith("]")) {
host = host.slice(1, host.length - 1);
}
return { host, port };
}
function fillConnectParams(params) {
2021-06-10 12:11:11 -04:00
let host = window.location.host || "localhost:8080";
let proto = "wss:";
if (window.location.protocol != "https:") {
proto = "ws:";
}
2021-06-10 12:11:11 -04:00
let path = window.location.pathname || "/";
if (!window.location.host) {
path = "/";
}
params = { ...params };
if (!params.url) {
params.url = proto + "//" + host + path + "socket";
}
if (params.url.startsWith("/")) {
params.url = proto + "//" + host + params.url;
}
if (params.url.indexOf("://") < 0) {
params.url = proto + "//" + params.url;
}
if (!params.username) {
params.username = params.nick;
}
if (!params.realname) {
params.realname = params.nick;
}
return params;
}
function showNotification(title, options) {
if (!window.Notification || Notification.permission !== "granted") {
return null;
}
// This can still fail due to:
// https://bugs.chromium.org/p/chromium/issues/detail?id=481856
try {
return new Notification(title, options);
} catch (err) {
console.error("Failed to show notification: ", err);
return null;
}
}
2022-02-11 14:59:31 -05:00
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) {
2022-02-12 04:21:11 -05:00
if (buf.name === "*") {
continue;
}
2022-02-12 04:21:11 -05:00
let receipt = getReceipt(buf, type);
if (isReceiptBefore(last, receipt)) {
last = receipt;
}
}
return last;
}
let lastErrorID = 0;
export default class App extends Component {
state = {
2021-09-21 06:33:22 -04:00
...State.create(),
connectParams: {
2021-05-31 12:04:02 -04:00
url: null,
pass: null,
username: null,
realname: null,
nick: null,
saslPlain: null,
saslExternal: false,
autoconnect: false,
autojoin: [],
ping: 0,
},
connectForm: true,
loading: true,
2021-03-08 10:23:16 -05:00
dialog: null,
2021-07-04 15:41:36 -04:00
dialogData: null,
error: null,
openPanels: {
bufferList: false,
memberList: false,
},
};
debug = !isProduction();
config = { ...baseConfig };
2021-01-22 11:36:53 -05:00
clients = new Map();
2020-06-29 03:06:47 -04:00
endOfHistory = new Map();
2020-07-15 12:21:09 -04:00
receipts = new Map();
buffer = createRef();
composer = createRef();
2021-05-25 06:40:33 -04:00
switchToChannel = null;
/**
* Parsed irc:// URL to automatically open. The user will be prompted for
* confirmation for security reasons.
*/
autoOpenURL = null;
messageNotifications = new Set();
baseTitle = null;
lastFocusPingDate = null;
constructor(props) {
super(props);
this.handleConnectSubmit = this.handleConnectSubmit.bind(this);
2021-03-08 10:23:16 -05:00
this.handleJoinSubmit = this.handleJoinSubmit.bind(this);
this.handleBufferListClick = this.handleBufferListClick.bind(this);
2021-10-17 13:33:02 -04:00
this.handleBufferListClose = this.handleBufferListClose.bind(this);
this.toggleBufferList = this.toggleBufferList.bind(this);
this.toggleMemberList = this.toggleMemberList.bind(this);
this.handleComposerSubmit = this.handleComposerSubmit.bind(this);
2021-05-31 22:39:35 -04:00
this.handleChannelClick = this.handleChannelClick.bind(this);
2020-06-25 12:45:41 -04:00
this.handleNickClick = this.handleNickClick.bind(this);
2020-06-29 06:36:17 -04:00
this.autocomplete = this.autocomplete.bind(this);
2020-06-29 03:06:47 -04:00
this.handleBufferScrollTop = this.handleBufferScrollTop.bind(this);
2021-07-04 15:41:36 -04:00
this.dismissDialog = this.dismissDialog.bind(this);
2021-03-08 12:15:04 -05:00
this.handleAddNetworkClick = this.handleAddNetworkClick.bind(this);
2021-03-09 13:10:22 -05:00
this.handleNetworkSubmit = this.handleNetworkSubmit.bind(this);
this.handleNetworkRemove = this.handleNetworkRemove.bind(this);
this.handleDismissError = this.handleDismissError.bind(this);
this.handleAuthSubmit = this.handleAuthSubmit.bind(this);
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.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
2023-06-08 09:07:28 -04:00
this.handleSwitchSubmit = this.handleSwitchSubmit.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.state.settings = {
...this.state.settings,
...store.settings.load(),
};
this.bufferStore = new store.Buffer();
2020-07-01 06:25:57 -04:00
configPromise.then((config) => {
this.handleConfig(config);
return config;
});
}
/**
* Handle configuration data and populate the connection parameters.
*
* The priority order is:
*
* - URL params
* - Saved parameters in local storage
* - Configuration data (fetched from the config.json file)
* - Default server URL constructed from the current URL location (this is
* done in fillConnectParams)
*/
async handleConfig(config) {
let connectParams = { ...this.state.connectParams };
if (typeof config.server.url === "string") {
connectParams.url = config.server.url;
}
if (Array.isArray(config.server.autojoin)) {
connectParams.autojoin = config.server.autojoin;
} else if (typeof config.server.autojoin === "string") {
connectParams.autojoin = [config.server.autojoin];
}
if (typeof config.server.nick === "string") {
connectParams.nick = config.server.nick;
}
if (typeof config.server.autoconnect === "boolean") {
connectParams.autoconnect = config.server.autoconnect;
}
if (config.server.auth === "external") {
connectParams.saslExternal = true;
}
if (typeof config.server.ping === "number") {
connectParams.ping = config.server.ping;
}
if (connectParams.autoconnect && config.server.auth === "mandatory") {
console.error("Error in config.json: cannot set server.autoconnect = true and server.auth = \"mandatory\"");
connectParams.autoconnect = false;
}
if (config.server.auth === "oauth2" && (!config.oauth2 || !config.oauth2.url || !config.oauth2.client_id)) {
console.error("Error in config.json: server.auth = \"oauth2\" requires oauth2 settings");
config.server.auth = null;
}
2021-06-10 12:11:11 -04:00
let autoconnect = store.autoconnect.load();
if (autoconnect) {
connectParams = {
...connectParams,
...autoconnect,
autoconnect: true,
autojoin: [], // handled by store.Buffer
};
}
2021-11-08 07:01:54 -05:00
let autojoin = [];
2021-06-10 12:11:11 -04:00
let queryParams = parseQueryString();
// Don't allow to silently override the server URL if there's one in
// config.json, because this has security implications. But still allow
// setting server to an empty string to reveal the server field in the
// connect form.
if (typeof queryParams.server === "string" && (!connectParams.url || !queryParams.server)) {
connectParams.url = queryParams.server;
// When using a custom server, some configuration options don't
// make sense anymore.
config.server.auth = null;
}
if (typeof queryParams.nick === "string") {
connectParams.nick = queryParams.nick;
}
if (typeof queryParams.channels === "string") {
2021-11-08 07:01:54 -05:00
autojoin = queryParams.channels.split(",");
2020-07-15 12:21:09 -04:00
}
if (typeof queryParams.open === "string") {
this.autoOpenURL = irc.parseURL(queryParams.open);
}
if (queryParams.debug === "1") {
this.debug = true;
}
if (window.location.hash) {
2021-11-08 07:01:54 -05:00
autojoin = window.location.hash.split(",");
}
this.config = config;
if (!connectParams.nick && connectParams.autoconnect) {
connectParams.nick = "user-*";
}
if (connectParams.nick && connectParams.nick.includes("*")) {
let placeholder = Math.random().toString(36).substr(2, 7);
connectParams.nick = connectParams.nick.replace("*", placeholder);
}
if (config.server.auth === "oauth2" && !connectParams.saslOauthBearer) {
if (queryParams.error) {
console.error("OAuth 2.0 authorization failed: ", queryParams.error);
this.showError("Authentication failed: " + (queryParams.error_description || queryParams.error));
return;
}
if (!queryParams.code) {
this.redirectOauth2Authorize();
return;
}
// Strip code from query params, to prevent page refreshes from
// trying to exchange the code again
let url = new URL(window.location.toString());
url.searchParams.delete("code");
url.searchParams.delete("state");
window.history.replaceState(null, "", url.toString());
let saslOauthBearer;
try {
saslOauthBearer = await this.exchangeOauth2Code(queryParams.code);
} catch (err) {
this.showError(err);
return;
}
connectParams.saslOauthBearer = saslOauthBearer;
if (saslOauthBearer.username && !connectParams.nick) {
connectParams.nick = saslOauthBearer.username;
}
}
2021-11-08 07:01:54 -05:00
if (autojoin.length > 0) {
if (connectParams.autoconnect) {
// Ask the user whether they want to join that new channel.
// TODO: support multiple channels here
this.autoOpenURL = { host: "", entity: autojoin[0] };
} else {
connectParams.autojoin = autojoin;
}
}
this.setState({ loading: false, connectParams: connectParams });
if (connectParams.autoconnect) {
this.setState({ connectForm: false });
this.connect(connectParams);
}
}
async redirectOauth2Authorize() {
let serverMetadata;
try {
serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url);
} catch (err) {
console.error("Failed to fetch OAuth 2.0 server metadata:", err);
this.showError("Failed to fetch OAuth 2.0 server metadata");
return;
}
oauth2.redirectAuthorize({
serverMetadata,
clientId: this.config.oauth2.client_id,
redirectUri: window.location.toString(),
scope: this.config.oauth2.scope,
});
}
async exchangeOauth2Code(code) {
let serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url);
let redirectUri = new URL(window.location.toString());
redirectUri.searchParams.delete("code");
redirectUri.searchParams.delete("state");
let data = await oauth2.exchangeCode({
serverMetadata,
redirectUri: redirectUri.toString(),
code,
clientId: this.config.oauth2.client_id,
clientSecret: this.config.oauth2.client_secret,
});
// TODO: handle expires_in/refresh_token
let token = data.access_token;
let username = null;
if (serverMetadata.introspection_endpoint) {
try {
let data = await oauth2.introspectToken({
serverMetadata,
token,
clientId: this.config.oauth2.client_id,
clientSecret: this.config.oauth2.client_secret,
});
username = data.username;
} catch (err) {
console.warn("Failed to introspect OAuth 2.0 token:", err);
}
}
return { token, username };
}
showError(err) {
console.error("App error: ", err);
let text;
if (err instanceof Error) {
let l = [];
while (err) {
l.push(err.message);
err = err.cause;
}
text = l.join(": ");
} else {
text = String(err);
}
this.setState({ error: text });
lastErrorID++;
return lastErrorID;
}
dismissError(id) {
if (id && id !== lastErrorID) {
return;
}
this.setState({ error: null });
}
handleDismissError(event) {
event.preventDefault();
this.dismissError();
}
setServerState(id, updater, callback) {
2021-01-21 13:01:50 -05:00
this.setState((state) => {
2021-06-04 12:03:03 -04:00
return State.updateServer(state, id, updater);
2021-01-21 13:01:50 -05:00
}, callback);
}
2021-01-21 14:41:44 -05:00
setBufferState(id, updater, callback) {
this.setState((state) => {
2021-06-04 12:03:03 -04:00
return State.updateBuffer(state, id, updater);
}, callback);
}
syncBufferUnread(serverID, name) {
let client = this.clients.get(serverID);
let stored = this.bufferStore.get({ name, server: client.params });
2021-12-10 09:34:51 -05:00
if (client.caps.enabled.has("draft/chathistory") && stored) {
this.setBufferState({ server: serverID, name }, { unread: stored.unread });
}
this.bufferStore.put({
name,
server: client.params,
closed: false,
});
}
createBuffer(serverID, name) {
let client = this.clients.get(serverID);
2021-06-10 12:11:11 -04:00
let id = null;
let isNew = false;
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let updated;
2021-06-04 12:37:34 -04:00
[id, updated] = State.createBuffer(state, name, serverID, client);
isNew = !!updated;
2021-06-04 12:37:34 -04:00
return updated;
});
if (isNew) {
this.syncBufferUnread(serverID, name);
}
return id;
}
2022-02-11 12:21:17 -05:00
sendReadReceipt(client, storedBuffer) {
if (!client.supportsReadMarker()) {
2022-02-11 12:21:17 -05:00
return;
}
let readReceipt = storedBuffer.receipts[ReceiptType.READ];
if (storedBuffer.name === "*" || !readReceipt) {
return;
}
client.setReadMarker(storedBuffer.name, readReceipt.time);
2022-02-11 12:21:17 -05:00
}
2021-01-21 16:15:33 -05:00
switchBuffer(id) {
let buf;
this.setState((state) => {
buf = State.getBuffer(state, id);
if (!buf) {
return;
}
return { activeBuffer: buf.id };
}, () => {
if (!buf) {
return;
}
if (this.buffer.current) {
this.buffer.current.focus();
}
let server = this.state.servers.get(buf.server);
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
this.whoChannelBuffer(buf.name, buf.server);
}
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
document.title = this.baseTitle;
}
});
// TODO: only mark as read if user scrolled at the bottom
this.markBufferAsRead(id);
}
markBufferAsRead(id) {
2021-06-10 12:11:11 -04:00
let buf;
2021-01-21 14:41:44 -05:00
this.setState((state) => {
2021-06-04 12:03:03 -04:00
buf = State.getBuffer(state, id);
2021-01-21 14:41:44 -05:00
if (!buf) {
return;
}
2021-01-21 16:15:33 -05:00
let client = this.clients.get(buf.server);
let stored = this.bufferStore.get({ name: buf.name, server: client.params });
2022-02-11 14:59:31 -05:00
let prevReadReceipt = getReceipt(stored, ReceiptType.READ);
return State.updateBuffer(state, buf.id, {
2021-01-21 16:15:33 -05:00
unread: Unread.NONE,
prevReadReceipt,
2021-01-21 16:15:33 -05:00
});
}, () => {
if (!buf) {
return;
}
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];
2022-02-11 12:21:17 -05:00
let stored = {
name: buf.name,
server: client.params,
unread: Unread.NONE,
receipts: { [ReceiptType.READ]: receiptFromMessage(lastMsg) },
2022-02-11 12:21:17 -05:00
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
2020-07-15 12:21:09 -04:00
}
});
}
prepareChatMessage(serverID, msg) {
2021-06-23 13:52:45 -04:00
// Treat server-wide broadcasts as highlights. They're sent by server
// operators and can contain important information.
if (msg.isHighlight === undefined) {
let client = this.clients.get(serverID);
msg.isHighlight = irc.isHighlight(msg, client.nick, client.cm) || irc.isServerBroadcast(msg);
}
if (!msg.tags) {
// Can happen for outgoing messages for instance
msg.tags = {};
}
2020-06-29 03:06:47 -04:00
if (!msg.tags.time) {
msg.tags.time = irc.formatDate(new Date());
}
}
addChatMessage(serverID, bufName, msg) {
this.prepareChatMessage(serverID, msg);
let bufID = { server: serverID, name: bufName };
this.setState((state) => State.addMessage(state, msg, bufID));
}
handleChatMessage(serverID, bufName, msg) {
let client = this.clients.get(serverID);
this.prepareChatMessage(serverID, msg);
2020-06-29 03:06:47 -04:00
let stored = this.bufferStore.get({ name: bufName, server: client.params });
2022-02-11 14:59:31 -05:00
let deliveryReceipt = getReceipt(stored, ReceiptType.DELIVERED);
let readReceipt = getReceipt(stored, ReceiptType.READ);
let isDelivered = isMessageBeforeReceipt(msg, deliveryReceipt);
let isRead = isMessageBeforeReceipt(msg, readReceipt);
2021-06-24 12:04:26 -04:00
if (client.isMyNick(msg.prefix.name)) {
isRead = true;
}
2021-06-10 12:11:11 -04:00
let msgUnread = Unread.NONE;
2020-07-15 12:21:09 -04:00
if ((msg.command == "PRIVMSG" || msg.command == "NOTICE") && !isRead) {
2021-06-10 12:57:57 -04:00
let target = msg.params[0];
2021-06-10 12:11:11 -04:00
let text = msg.params[1];
2020-06-29 05:50:42 -04:00
2021-06-10 12:11:11 -04:00
let kind;
if (msg.isHighlight) {
2020-06-29 05:08:47 -04:00
msgUnread = Unread.HIGHLIGHT;
2020-06-29 05:50:42 -04:00
kind = "highlight";
2021-06-10 12:57:57 -04:00
} else if (client.isMyNick(target)) {
2020-06-29 05:50:42 -04:00
msgUnread = Unread.HIGHLIGHT;
kind = "private message";
2020-06-29 05:08:47 -04:00
} else {
msgUnread = Unread.MESSAGE;
}
2020-06-29 05:50:42 -04:00
if (msgUnread == Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) {
2021-06-10 12:11:11 -04:00
let title = "New " + kind + " from " + msg.prefix.name;
if (client.isChannel(bufName)) {
title += " in " + bufName;
2020-06-29 05:50:42 -04:00
}
2021-06-10 12:11:11 -04:00
let notif = showNotification(title, {
body: stripANSI(text),
2020-06-29 05:50:42 -04:00
requireInteraction: true,
tag: "msg,server=" + serverID + ",from=" + msg.prefix.name + ",to=" + bufName,
data: { bufferName: bufName, message: msg },
2020-06-29 05:50:42 -04:00
});
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);
}
2020-06-29 05:50:42 -04:00
}
2020-06-24 10:56:28 -04:00
}
if (msg.command === "INVITE" && client.isMyNick(msg.params[0])) {
msgUnread = Unread.HIGHLIGHT;
2021-06-10 12:11:11 -04:00
let channel = msg.params[1];
let notif = new Notification("Invitation to " + channel, {
body: msg.prefix.name + " has invited you to " + channel,
requireInteraction: true,
tag: "invite,server=" + serverID + ",from=" + msg.prefix.name + ",channel=" + channel,
actions: [{
action: "accept",
title: "Accept",
}],
});
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 });
2022-02-11 12:21:17 -05:00
}
});
}
}
2020-06-24 10:56:28 -04:00
// Open a new buffer if the message doesn't come from me or is a
// self-message
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command != "PART" && msg.comand != "QUIT" && msg.command != irc.RPL_MONONLINE && msg.command != irc.RPL_MONOFFLINE)) {
this.createBuffer(serverID, bufName);
}
2021-06-10 12:11:11 -04:00
let bufID = { server: serverID, name: bufName };
2021-06-04 13:07:14 -04:00
this.setState((state) => State.addMessage(state, msg, bufID));
this.setBufferState(bufID, (buf) => {
2020-07-15 12:21:09 -04:00
// TODO: set unread if scrolled up
2021-06-10 12:11:11 -04:00
let unread = buf.unread;
let prevReadReceipt = buf.prevReadReceipt;
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer !== buf.id || !document.hasFocus()) {
2020-06-24 11:46:43 -04:00
unread = Unread.union(unread, msgUnread);
2020-07-15 12:21:09 -04:00
} else {
receipts[ReceiptType.READ] = receiptFromMessage(msg);
2020-06-24 10:56:28 -04:00
}
// Don't show unread marker for my own messages
2022-02-11 10:37:58 -05:00
if (client.isMyNick(msg.prefix.name) && !isMessageBeforeReceipt(msg, prevReadReceipt)) {
prevReadReceipt = receiptFromMessage(msg);
}
2022-02-11 12:21:17 -05:00
let stored = {
name: buf.name,
server: client.params,
unread,
receipts,
2022-02-11 12:21:17 -05:00
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
return { unread, prevReadReceipt };
});
}
2021-01-22 12:44:06 -05:00
connect(params) {
// Merge our previous connection params so that config options such as
// the ping interval are applied
params = {
...this.state.connectParams,
...params,
};
2021-06-10 12:11:11 -04:00
let serverID = null;
2021-01-21 13:01:50 -05:00
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let update;
2021-06-10 04:54:33 -04:00
[serverID, update] = State.createServer(state);
return update;
2021-01-21 13:01:50 -05:00
});
this.setState({ connectParams: params });
let client = new Client({
...fillConnectParams(params),
eventPlayback: this.state.settings.bufferEvents !== BufferEventsDisplayMode.HIDE,
});
client.debug = this.debug;
this.clients.set(serverID, client);
2021-06-10 04:54:33 -04:00
this.setServerState(serverID, { status: client.status });
2021-01-22 11:36:53 -05:00
let errorID = null;
2021-01-22 12:29:22 -05:00
client.addEventListener("status", () => {
this.setServerState(serverID, { status: client.status });
switch (client.status) {
case Client.Status.DISCONNECTED:
this.setServerState(serverID, { account: null });
this.setState((state) => {
let buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server !== serverID) {
return;
}
buffers.set(buf.id, { ...buf, joined: false });
});
return { buffers };
});
break;
case Client.Status.REGISTERED:
this.setState({ connectForm: false });
if (errorID) {
this.dismissError(errorID);
}
break;
}
2021-01-21 13:01:50 -05:00
});
2021-01-22 11:36:53 -05:00
client.addEventListener("message", (event) => {
this.handleMessage(serverID, event.detail.message);
});
2021-01-22 11:36:53 -05:00
client.addEventListener("error", (event) => {
errorID = this.showError(event.detail);
});
this.createBuffer(serverID, SERVER_BUFFER);
if (!this.state.activeBuffer) {
this.switchBuffer({ server: serverID, name: SERVER_BUFFER });
}
2021-05-25 06:40:33 -04:00
if (params.autojoin.length > 0) {
this.switchToChannel = params.autojoin[0];
}
}
disconnect(serverID) {
if (!serverID) {
2021-06-04 12:03:03 -04:00
serverID = State.getActiveServerID(this.state);
}
2021-06-10 12:11:11 -04:00
let client = this.clients.get(serverID);
2021-01-22 11:36:53 -05:00
if (client) {
this.clients.delete(serverID);
2021-01-22 12:29:22 -05:00
client.disconnect();
2021-01-12 04:35:38 -05:00
}
}
reconnect(serverID) {
if (!serverID) {
2021-06-04 12:03:03 -04:00
serverID = State.getActiveServerID(this.state);
}
2021-06-10 12:11:11 -04:00
let client = this.clients.get(serverID);
2021-01-22 12:44:06 -05:00
if (client) {
client.reconnect();
}
2021-01-12 04:35:38 -05:00
}
serverFromBouncerNetwork(bouncerNetworkID) {
2021-06-10 12:11:11 -04:00
for (let [id, client] of this.clients) {
if (client.params.bouncerNetwork === bouncerNetworkID) {
return id;
}
}
return null;
}
routeMessage(serverID, msg) {
2021-06-10 12:11:11 -04:00
let client = this.clients.get(serverID);
let chatHistoryBatch = irc.findBatchByType(msg, "chathistory");
// Reply triggered by some command sent by us, not worth displaying to
// the user
if (msg.internal) {
return [];
}
let target, channel, affectedBuffers;
switch (msg.command) {
case "MODE":
target = msg.params[0];
if (client.isChannel(target)) {
return [target];
}
return [SERVER_BUFFER];
case "NOTICE":
case "PRIVMSG":
target = msg.params[0];
if (client.isMyNick(target)) {
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
target = SERVER_BUFFER;
} else {
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;
}
}
}
// Don't open a new buffer if this is just a NOTICE or a garbage
// CTCP message
let openNewBuffer = true;
if (msg.command !== "PRIVMSG") {
openNewBuffer = false;
} else {
let ctcp = irc.parseCTCP(msg);
if (ctcp && ctcp.command !== "ACTION") {
openNewBuffer = false;
}
}
if (!openNewBuffer && !State.getBuffer(this.state, { server: serverID, name: target })) {
target = SERVER_BUFFER;
}
let allowedPrefixes = client.isupport.statusMsg();
if (allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
if (client.isChannel(parts.name)) {
target = parts.name;
}
}
return [target];
case "JOIN":
channel = msg.params[0];
if (!client.isMyNick(msg.prefix.name)) {
return [channel];
}
return [];
case "PART":
channel = msg.params[0];
return [channel];
case "KICK":
channel = msg.params[0];
return [channel];
case "QUIT":
affectedBuffers = [];
if (chatHistoryBatch) {
affectedBuffers.push(chatHistoryBatch.params[0]);
} else {
this.state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
return;
}
affectedBuffers.push(buf.name);
});
}
return affectedBuffers;
case "NICK":
let newNick = msg.params[0];
affectedBuffers = [];
if (chatHistoryBatch) {
affectedBuffers.push(chatHistoryBatch.params[0]);
} else {
this.state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
return;
}
affectedBuffers.push(buf.name);
});
if (client.isMyNick(newNick)) {
affectedBuffers.push(SERVER_BUFFER);
}
}
return affectedBuffers;
case "TOPIC":
channel = msg.params[0];
return [channel];
case "INVITE":
channel = msg.params[1];
// TODO: find a more reliable way to do this
let bufName = channel;
if (!State.getBuffer(this.state, { server: serverID, name: channel })) {
bufName = SERVER_BUFFER;
}
return [bufName];
case irc.RPL_CHANNELMODEIS:
case irc.RPL_CREATIONTIME:
case irc.RPL_INVITELIST:
case irc.RPL_ENDOFINVITELIST:
case irc.RPL_EXCEPTLIST:
case irc.RPL_ENDOFEXCEPTLIST:
case irc.RPL_BANLIST:
case irc.RPL_ENDOFBANLIST:
case irc.RPL_QUIETLIST:
case irc.RPL_ENDOFQUIETLIST:
channel = msg.params[1];
return [channel];
case irc.RPL_INVITING:
channel = msg.params[2];
return [channel];
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
let targets = msg.params[1].split(",");
affectedBuffers = [];
for (let target of targets) {
let prefix = irc.parsePrefix(target);
affectedBuffers.push(prefix.name);
}
return affectedBuffers;
case irc.RPL_YOURHOST:
case irc.RPL_MYINFO:
case irc.RPL_ISUPPORT:
case irc.RPL_ENDOFMOTD:
case irc.ERR_NOMOTD:
2021-11-23 11:58:49 -05:00
case irc.RPL_AWAY:
case irc.RPL_NOTOPIC:
case irc.RPL_TOPIC:
case irc.RPL_TOPICWHOTIME:
case irc.RPL_NAMREPLY:
case irc.RPL_ENDOFNAMES:
case irc.RPL_SASLSUCCESS:
2022-08-22 04:35:50 -04:00
case irc.RPL_CHANNEL_URL:
case "AWAY":
case "SETNAME":
case "CHGHOST":
case "ACCOUNT":
case "CAP":
case "AUTHENTICATE":
case "PING":
case "PONG":
case "BATCH":
case "TAGMSG":
case "CHATHISTORY":
case "ACK":
case "BOUNCER":
case "MARKREAD":
// Ignore these
return [];
default:
return [SERVER_BUFFER];
}
}
handleMessage(serverID, msg) {
let client = this.clients.get(serverID);
if (irc.findBatchByType(msg, "chathistory")) {
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:
this.fetchBacklog(serverID);
break;
case irc.RPL_ENDOFMOTD:
case irc.ERR_NOMOTD:
// These messages are used to indicate the end of the ISUPPORT list
// Restore opened channel and user buffers
let join = [];
for (let buf of this.bufferStore.list(client.params)) {
if (buf.name === "*" || buf.closed) {
continue;
}
if (client.isChannel(buf.name)) {
2021-12-10 09:34:51 -05:00
if (client.caps.enabled.has("soju.im/bouncer-networks")) {
continue;
}
join.push(buf.name);
} else {
this.createBuffer(serverID, buf.name);
this.whoUserBuffer(buf.name, serverID);
}
}
// Auto-join channels given at connect-time
let server = this.state.servers.get(serverID);
let bouncerNetID = server.bouncerNetID;
let bouncerNetwork = null;
if (bouncerNetID) {
bouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
}
if (!bouncerNetwork || bouncerNetwork.state === "connected") {
join = join.concat(client.params.autojoin);
client.params.autojoin = [];
}
if (join.length > 0) {
client.send({
command: "JOIN",
params: [join.join(",")],
});
}
let serverHost = bouncerNetwork ? bouncerNetwork.host : "";
if (this.autoOpenURL && serverHost === this.autoOpenURL.host) {
this.openURL(this.autoOpenURL);
this.autoOpenURL = null;
}
case "JOIN":
2021-06-10 12:11:11 -04:00
channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) {
this.syncBufferUnread(serverID, channel);
}
2021-05-25 06:40:33 -04:00
if (channel == this.switchToChannel) {
this.switchBuffer({ server: serverID, name: channel });
2021-05-25 06:40:33 -04:00
this.switchToChannel = null;
}
break;
case "BOUNCER":
if (msg.params[0] !== "NETWORK") {
break; // We're only interested in network updates
}
if (client.isupport.bouncerNetID()) {
// This can happen if the user has specified a network to bind
// to via other means, e.g. "<username>/<network>".
break;
}
2021-06-10 12:11:11 -04:00
let id = msg.params[1];
let attrs = null;
if (msg.params[2] !== "*") {
attrs = irc.parseTags(msg.params[2]);
}
2021-06-10 12:11:11 -04:00
let isNew = false;
this.setState((state) => {
if (!attrs) {
2022-02-04 08:22:50 -05:00
return State.deleteBouncerNetwork(state, id);
} else {
2022-02-04 08:22:50 -05:00
isNew = !state.bouncerNetworks.has(id);
return State.storeBouncerNetwork(state, id, attrs);
}
}, () => {
if (!attrs) {
2021-06-10 12:11:11 -04:00
let serverID = this.serverFromBouncerNetwork(id);
if (serverID) {
this.close({ server: serverID, name: SERVER_BUFFER });
}
} else if (isNew) {
this.connect({
...client.params,
bouncerNetwork: id,
});
}
if (attrs && attrs.state === "connected") {
let serverID = this.serverFromBouncerNetwork(id);
let client = this.clients.get(serverID);
if (client && client.status === Client.Status.REGISTERED && client.params.autojoin && client.params.autojoin.length > 0) {
client.send({
command: "JOIN",
params: [client.params.autojoin.join(",")],
});
client.params.autojoin = [];
}
}
});
break;
case "BATCH":
if (!msg.params[0].startsWith("-")) {
break;
}
let name = msg.params[0].slice(1);
let batch = client.batches.get(name);
if (!batch || batch.type !== "soju.im/bouncer-networks") {
break;
}
// We've received a BOUNCER NETWORK batch. If we have a URL to
// auto-open and no existing network matches it, ask the user to
// create a new network.
if (this.autoOpenURL && this.autoOpenURL.host && !this.findBouncerNetIDByHost(this.autoOpenURL.host)) {
this.openURL(this.autoOpenURL);
this.autoOpenURL = null;
}
break;
case "MARKREAD":
2022-02-11 12:21:17 -05:00
target = msg.params[0];
let bound = msg.params[1];
if (bound === "*" || !bound.startsWith("timestamp=")) {
2022-02-11 12:21:17 -05:00
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();
}
}
let unread;
let closed = true;
2022-02-11 12:21:17 -05:00
this.setBufferState({ server: serverID, name: target }, (buf) => {
closed = false;
2022-02-11 12:21:17 -05:00
// Re-compute unread status
unread = Unread.NONE;
2022-02-11 12:21:17 -05:00
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;
}
return { unread };
}, () => {
this.bufferStore.put({
name: target,
server: client.params,
unread,
closed,
receipts: { [ReceiptType.READ]: readReceipt },
});
2022-02-11 12:21:17 -05:00
});
break;
default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
2021-06-10 12:11:11 -04:00
let description = msg.params[msg.params.length - 1];
this.showError(description);
}
}
destBuffers.forEach((bufName) => {
this.handleChatMessage(serverID, bufName, msg);
});
}
2023-04-19 06:51:13 -04:00
async 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());
2023-04-19 06:51:13 -04:00
let targets = await client.fetchHistoryTargets(now, lastReceipt.time);
targets.forEach(async (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);
}
2023-04-19 06:51:13 -04:00
// Query read marker if this is a user (ie, we haven't received
// the read marker as part of a JOIN burst)
if (client.supportsReadMarker() && client.isNick(target.name)) {
client.fetchReadMarker(target.name);
}
2023-04-19 06:51:13 -04:00
let result;
try {
result = await 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 + "'");
return;
}
2023-04-19 06:51:13 -04:00
for (let msg of result.messages) {
let destBuffers = this.routeMessage(serverID, msg);
for (let bufName of destBuffers) {
this.handleChatMessage(serverID, bufName, msg);
}
}
});
}
handleConnectSubmit(connectParams) {
this.dismissError();
if (connectParams.autoconnect) {
store.autoconnect.put(connectParams);
} else {
store.autoconnect.put(null);
}
// Disconnect previous server, if any
let activeBuffer = this.state.buffers.get(this.state.activeBuffer);
if (activeBuffer) {
this.close(activeBuffer.server);
}
2021-01-22 12:44:06 -05:00
this.connect(connectParams);
}
handleChannelClick(event) {
let handled = this.openURL(event.target.href);
if (handled) {
event.preventDefault();
}
}
findBouncerNetIDByHost(host) {
for (let [id, bouncerNetwork] of this.state.bouncerNetworks) {
if (bouncerNetwork.host === host) {
return id;
}
}
return null;
}
openURL(url) {
if (typeof url === "string") {
url = irc.parseURL(url);
}
if (!url) {
return false;
}
2021-11-16 05:52:38 -05:00
let { host, port } = splitHostPort(url.host);
let serverID;
if (!url.host) {
serverID = State.getActiveServerID(this.state);
} else {
2021-11-16 05:52:38 -05:00
let bouncerNetID = this.findBouncerNetIDByHost(host);
if (!bouncerNetID) {
// Open dialog to create network if bouncer
let client = this.clients.values().next().value;
2021-12-10 09:34:51 -05:00
if (!client || !client.caps.enabled.has("soju.im/bouncer-networks")) {
return false;
}
2021-11-16 05:52:38 -05:00
let params = { host };
if (typeof port === "number") {
params.port = port;
}
this.openDialog("network", { params, autojoin: url.entity });
return true;
}
for (let [id, server] of this.state.servers) {
if (server.bouncerNetID === bouncerNetID) {
serverID = id;
break;
}
}
}
if (!serverID) {
return false;
}
let buf = State.getBuffer(this.state, { server: serverID, name: url.entity || SERVER_BUFFER });
2021-05-31 22:39:35 -04:00
if (buf) {
this.switchBuffer(buf.id);
} else {
this.openDialog("join", { server: serverID, channel: url.entity });
2021-05-31 22:39:35 -04:00
}
return true;
2021-05-31 22:39:35 -04:00
}
2020-06-25 12:45:41 -04:00
handleNickClick(nick) {
this.open(nick);
}
whoUserBuffer(target, serverID) {
let client = this.clients.get(serverID);
client.who(target, {
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
});
client.monitor(target);
2022-02-11 12:21:17 -05:00
if (client.supportsReadMarker()) {
client.fetchReadMarker(target);
2022-02-11 12:21:17 -05:00
}
}
2023-04-19 06:51:13 -04:00
async whoChannelBuffer(target, serverID) {
let client = this.clients.get(serverID);
// Prevent multiple WHO commands for the same channel in parallel
2023-04-19 06:51:13 -04:00
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
let hasInitialWho = false;
try {
await client.who(target, {
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
});
hasInitialWho = true;
} finally {
this.setBufferState({ name: target, server: serverID }, { hasInitialWho });
}
}
2022-02-02 11:40:19 -05:00
open(target, serverID, password) {
if (!serverID) {
2021-06-04 12:03:03 -04:00
serverID = State.getActiveServerID(this.state);
}
2021-06-10 12:11:11 -04:00
let client = this.clients.get(serverID);
if (client.isServer(target)) {
this.switchBuffer({ server: serverID });
} else if (client.isChannel(target)) {
this.switchToChannel = target;
client.join(target, password).catch((err) => {
this.showError(err);
});
} else {
this.whoUserBuffer(target, serverID);
this.createBuffer(serverID, target);
this.switchBuffer({ server: serverID, name: target });
2020-06-25 12:45:41 -04:00
}
}
2021-01-22 04:26:53 -05:00
close(id) {
2021-06-10 12:11:11 -04:00
let buf = State.getBuffer(this.state, id);
2021-01-22 04:26:53 -05:00
if (!buf) {
return;
}
2021-06-10 12:11:11 -04:00
let client = this.clients.get(buf.server);
2021-01-22 04:26:53 -05:00
switch (buf.type) {
case BufferType.SERVER:
2021-03-10 04:59:39 -05:00
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let buffers = new Map(state.buffers);
for (let [id, b] of state.buffers) {
if (b.server === buf.server) {
2021-03-10 04:59:39 -05:00
buffers.delete(id);
}
}
2021-06-10 12:11:11 -04:00
let activeBuffer = state.activeBuffer;
if (activeBuffer && state.buffers.get(activeBuffer).server === buf.server) {
2021-03-10 04:59:39 -05:00
if (buffers.size > 0) {
activeBuffer = buffers.keys().next().value;
} else {
activeBuffer = null;
}
}
return { buffers, activeBuffer };
2021-01-11 12:12:28 -05:00
});
2021-03-10 04:59:39 -05:00
2021-12-10 09:34:51 -05:00
let disconnectAll = client && !client.params.bouncerNetwork && client.caps.enabled.has("soju.im/bouncer-networks");
let isFirstServer = this.state.servers.keys().next().value === buf.server;
2021-03-10 04:59:39 -05:00
this.disconnect(buf.server);
2021-03-10 04:59:39 -05:00
2021-01-22 04:41:28 -05:00
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let servers = new Map(state.servers);
servers.delete(buf.server);
2021-06-10 12:11:11 -04:00
let connectForm = state.connectForm;
if (servers.size == 0) {
connectForm = true;
}
return { servers, connectForm };
2021-01-22 04:41:28 -05:00
});
2021-03-10 04:59:39 -05:00
if (disconnectAll) {
2021-06-10 12:11:11 -04:00
for (let serverID of this.clients.keys()) {
this.close({ server: serverID, name: SERVER_BUFFER });
2021-03-10 04:59:39 -05:00
}
this.bufferStore.clear();
} else {
this.bufferStore.clear(client.params);
2021-03-10 04:59:39 -05:00
}
// TODO: only clear autoconnect if this server is stored there
if (isFirstServer) {
store.autoconnect.put(null);
}
2021-01-22 04:26:53 -05:00
break;
case BufferType.CHANNEL:
if (buf.joined) {
client.send({ command: "PART", params: [buf.name] });
}
2021-01-22 04:26:53 -05:00
// fallthrough
case BufferType.NICK:
if (this.state.activeBuffer === buf.id) {
this.switchBuffer({ name: SERVER_BUFFER });
}
2021-01-22 04:26:53 -05:00
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let buffers = new Map(state.buffers);
2021-03-03 03:59:40 -05:00
buffers.delete(buf.id);
2021-01-22 04:26:53 -05:00
return { buffers };
});
2020-07-15 12:21:09 -04:00
2021-08-24 06:53:46 -04:00
client.unmonitor(buf.name);
this.bufferStore.put({
name: buf.name,
server: client.params,
closed: true,
});
2021-01-22 04:26:53 -05:00
break;
}
}
disconnectAll() {
this.close(this.state.buffers.keys().next().value);
}
executeCommand(s) {
2021-06-10 12:11:11 -04:00
let parts = s.split(" ");
let name = parts[0].toLowerCase().slice(1);
let args = parts.slice(1);
2020-07-13 11:22:24 -04:00
2021-06-10 12:11:11 -04:00
let cmd = commands[name];
2020-07-13 11:22:24 -04:00
if (!cmd) {
this.showError(`Unknown command "${name}" (run "/help" to get a command list)`);
2020-07-13 11:22:24 -04:00
return;
}
try {
2021-03-08 11:25:00 -05:00
cmd.execute(this, args);
} catch (error) {
console.error(`Failed to execute command "${name}":`, error);
this.showError(error.message);
}
}
2020-06-28 03:29:39 -04:00
privmsg(target, text) {
if (target == SERVER_BUFFER) {
this.showError("Cannot send message in server buffer");
2020-06-28 03:29:39 -04:00
return;
}
2021-06-10 12:11:11 -04:00
let serverID = State.getActiveServerID(this.state);
let client = this.clients.get(serverID);
2021-01-22 11:36:53 -05:00
2021-06-10 12:11:11 -04:00
let msg = { command: "PRIVMSG", params: [target, text] };
2021-01-22 11:36:53 -05:00
client.send(msg);
2020-06-28 03:29:39 -04:00
2021-12-10 09:34:51 -05:00
if (!client.caps.enabled.has("echo-message")) {
2021-01-22 11:36:53 -05:00
msg.prefix = { name: client.nick };
this.handleChatMessage(serverID, target, msg);
2020-06-28 03:29:39 -04:00
}
}
handleComposerSubmit(text) {
if (!text) {
return;
}
if (text.startsWith("//")) {
text = text.slice(1);
} else if (text.startsWith("/")) {
this.executeCommand(text);
return;
}
2021-06-10 12:11:11 -04:00
let buf = this.state.buffers.get(this.state.activeBuffer);
2021-01-21 14:41:44 -05:00
if (!buf) {
return;
}
2021-01-21 14:41:44 -05:00
this.privmsg(buf.name, text);
}
handleBufferListClick(id) {
this.switchBuffer(id);
this.closeBufferList();
}
2021-10-17 13:33:02 -04:00
handleBufferListClose(id) {
this.close(id);
this.closeBufferList();
}
toggleBufferList() {
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let openPanels = {
...state.openPanels,
bufferList: !state.openPanels.bufferList,
};
return { openPanels };
});
}
toggleMemberList() {
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let openPanels = {
...state.openPanels,
memberList: !state.openPanels.memberList,
};
return { openPanels };
});
}
closeBufferList() {
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let openPanels = {
...state.openPanels,
bufferList: false,
};
return { openPanels };
});
}
closeMemberList() {
this.setState((state) => {
2021-06-10 12:11:11 -04:00
let openPanels = {
...state.openPanels,
memberList: false,
};
return { openPanels };
});
}
handleJoinClick(buf) {
switch (buf.type) {
case BufferType.SERVER:
this.openDialog("join", { server: buf.server });
break;
case BufferType.CHANNEL:
let client = this.clients.get(buf.server);
client.send({ command: "JOIN", params: [buf.name] });
break;
}
2021-03-08 10:23:16 -05:00
}
2021-01-22 11:36:53 -05:00
2021-03-08 10:23:16 -05:00
handleJoinSubmit(data) {
this.open(data.channel, this.state.dialogData.server);
2021-07-04 15:41:36 -04:00
this.dismissDialog();
2020-06-29 04:12:46 -04:00
}
2020-06-29 06:36:17 -04:00
autocomplete(prefix) {
2020-07-13 11:28:49 -04:00
function fromList(l, prefix) {
prefix = prefix.toLowerCase();
let repl = [];
2021-06-10 12:11:11 -04:00
for (let item of l) {
2020-07-13 11:28:49 -04:00
if (item.toLowerCase().startsWith(prefix)) {
repl.push(item);
2020-07-13 11:28:49 -04:00
}
}
return repl;
2020-06-29 06:36:17 -04:00
}
2020-07-13 11:28:49 -04:00
if (prefix.startsWith("/")) {
2021-06-10 12:11:11 -04:00
let repl = fromList(Object.keys(commands), prefix.slice(1));
return repl.map(cmd => "/" + cmd);
2020-07-13 11:28:49 -04:00
}
// TODO: consider using the CHANTYPES ISUPPORT token here
if (prefix.startsWith("#")) {
let chanNames = [];
for (const buf of this.state.buffers.values()) {
if (buf.name.startsWith("#")) {
chanNames.push(buf.name);
}
}
return fromList(chanNames, prefix);
}
2021-06-10 12:11:11 -04:00
let buf = this.state.buffers.get(this.state.activeBuffer);
2021-01-21 14:41:44 -05:00
if (!buf || !buf.members) {
return [];
2020-06-29 06:36:17 -04:00
}
2020-07-13 11:28:49 -04:00
return fromList(buf.members.keys(), prefix);
2020-06-29 06:36:17 -04:00
}
openHelp() {
2021-07-04 15:41:36 -04:00
this.openDialog("help");
}
2023-04-19 06:51:13 -04:00
async handleBufferScrollTop() {
2021-06-10 12:11:11 -04:00
let buf = this.state.buffers.get(this.state.activeBuffer);
2021-01-21 14:41:44 -05:00
if (!buf || buf.type == BufferType.SERVER) {
2020-06-29 03:06:47 -04:00
return;
}
2021-01-22 11:36:53 -05:00
2021-06-10 12:11:11 -04:00
let client = this.clients.get(buf.server);
2021-01-22 11:36:53 -05:00
2021-12-10 09:34:51 -05:00
if (!client || !client.caps.enabled.has("draft/chathistory") || !client.caps.enabled.has("server-time")) {
2020-06-29 03:06:47 -04:00
return;
}
2021-01-22 09:49:22 -05:00
if (this.endOfHistory.get(buf.id)) {
2020-06-29 03:06:47 -04:00
return;
}
2021-06-10 12:11:11 -04:00
let before;
2020-06-29 03:06:47 -04:00
if (buf.messages.length > 0) {
before = buf.messages[0].tags["time"];
} else {
before = irc.formatDate(new Date());
}
2020-07-15 12:21:09 -04:00
// Avoids sending multiple CHATHISTORY commands in parallel
2021-01-22 09:49:22 -05:00
this.endOfHistory.set(buf.id, true);
2020-07-15 12:21:09 -04:00
let limit = 100;
2021-12-10 09:34:51 -05:00
if (client.caps.enabled.has("draft/event-playback")) {
limit = 200;
}
2023-04-19 06:51:13 -04:00
let result = await client.fetchHistoryBefore(buf.name, before, limit);
this.endOfHistory.set(buf.id, !result.more);
2023-04-19 06:51:13 -04:00
if (result.messages.length > 0) {
let msg = result.messages[result.messages.length - 1];
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer === buf.id) {
receipts[ReceiptType.READ] = receiptFromMessage(msg);
}
2023-04-19 06:51:13 -04:00
let stored = {
name: buf.name,
server: client.params,
receipts,
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
2023-04-19 06:51:13 -04:00
this.setBufferState(buf, ({ prevReadReceipt }) => {
if (!isMessageBeforeReceipt(msg, prevReadReceipt)) {
prevReadReceipt = receiptFromMessage(msg);
}
return { prevReadReceipt };
});
}
for (let msg of result.messages) {
this.addChatMessage(buf.server, buf.name, msg);
}
2020-06-29 03:06:47 -04:00
}
2021-07-04 15:41:36 -04:00
openDialog(name, data) {
this.setState({ dialog: name, dialogData: data });
}
dismissDialog() {
this.setState({ dialog: null, dialogData: null });
2021-03-08 10:23:16 -05:00
}
2021-11-30 10:05:08 -05:00
setDialogLoading(promise) {
const setLoading = (loading) => {
this.setState((state) => {
return { dialogData: { ...state.dialogData, loading } };
});
};
setLoading(true);
promise.finally(() => setLoading(false));
}
handleAuthClick(serverID) {
let client = this.clients.get(serverID);
this.openDialog("auth", { username: client.nick });
}
handleAuthSubmit(username, password) {
let serverID = State.getActiveServerID(this.state);
let client = this.clients.get(serverID);
2021-11-30 10:05:08 -05:00
let promise = client.authenticate("PLAIN", { username, password }).then(() => {
this.dismissDialog();
let firstClient = this.clients.values().next().value;
if (client !== firstClient) {
return;
}
let autoconnect = store.autoconnect.load();
if (!autoconnect) {
return;
}
console.log("Saving SASL PLAIN credentials");
autoconnect = {
...autoconnect,
saslPlain: { username, password },
};
store.autoconnect.put(autoconnect);
});
2021-11-30 10:05:08 -05:00
this.setDialogLoading(promise);
}
handleRegisterClick(serverID) {
let client = this.clients.get(serverID);
let emailRequired = client.checkAccountRegistrationCap("email-required");
this.openDialog("register", { emailRequired });
}
handleRegisterSubmit(email, password) {
let serverID = State.getActiveServerID(this.state);
let client = this.clients.get(serverID);
// TODO: show registration status (pending/error) in dialog
let promise = client.registerAccount(email, password).then((data) => {
this.dismissDialog();
if (data.verificationRequired) {
this.handleVerifyClick(data.account, data.message);
}
let firstClient = this.clients.values().next().value;
if (client !== firstClient) {
return;
}
let autoconnect = store.autoconnect.load();
if (!autoconnect) {
return;
}
console.log("Saving account registration credentials");
autoconnect = {
...autoconnect,
saslPlain: { username: data.account, password },
};
store.autoconnect.put(autoconnect);
});
this.setDialogLoading(promise);
}
handleVerifyClick(account, message) {
this.openDialog("verify", { account, message });
}
handleVerifySubmit(code) {
let serverID = State.getActiveServerID(this.state);
let client = this.clients.get(serverID);
// TODO: display verification status (pending/error) in dialog
let promise = client.verifyAccount(this.state.dialogData.account, code).then(() => {
this.dismissDialog();
});
this.setDialogLoading(promise);
}
2021-03-08 12:15:04 -05:00
handleAddNetworkClick() {
2021-07-04 15:41:36 -04:00
this.openDialog("network");
2021-03-08 12:15:04 -05:00
}
handleManageNetworkClick(serverID) {
2021-06-10 12:11:11 -04:00
let server = this.state.servers.get(serverID);
let bouncerNetID = server.bouncerNetID;
2021-06-10 12:11:11 -04:00
let bouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
2021-07-04 15:41:36 -04:00
this.openDialog("network", {
id: bouncerNetID,
params: bouncerNetwork,
2021-03-09 13:10:22 -05:00
});
}
2023-04-19 06:51:13 -04:00
async handleNetworkSubmit(attrs, autojoin) {
2021-06-10 12:11:11 -04:00
let client = this.clients.values().next().value;
2021-03-09 13:10:22 -05:00
2023-04-19 06:51:13 -04:00
this.dismissDialog();
2021-07-04 15:41:36 -04:00
if (this.state.dialogData && this.state.dialogData.id) {
2021-03-09 13:10:22 -05:00
if (Object.keys(attrs).length == 0) {
return;
}
client.send({
command: "BOUNCER",
2021-07-04 15:41:36 -04:00
params: ["CHANGENETWORK", this.state.dialogData.id, irc.formatTags(attrs)],
2021-03-09 13:10:22 -05:00
});
} else {
attrs = { ...attrs, tls: "1" };
2023-04-19 06:51:13 -04:00
let id = await client.createBouncerNetwork(attrs);
if (!autojoin) {
return;
}
2023-04-19 06:51:13 -04:00
// By this point, bouncer-networks-notify should've advertised
// the new network
let serverID = this.serverFromBouncerNetwork(id);
let newClient = this.clients.get(serverID);
newClient.params.autojoin = [autojoin];
2023-04-19 06:51:13 -04:00
this.switchToChannel = autojoin;
2021-03-09 13:10:22 -05:00
}
}
handleNetworkRemove() {
2021-06-10 12:11:11 -04:00
let client = this.clients.values().next().value;
2021-03-09 13:10:22 -05:00
2021-03-08 12:15:04 -05:00
client.send({
command: "BOUNCER",
2021-07-04 15:41:36 -04:00
params: ["DELNETWORK", this.state.dialogData.id],
2021-03-08 12:15:04 -05:00
});
2021-03-09 13:10:22 -05:00
2021-07-04 15:41:36 -04:00
this.dismissDialog();
2021-03-08 12:15:04 -05:00
}
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 });
}
handleSettingsDisconnect() {
this.dismissDialog();
this.disconnectAll();
}
2023-06-08 09:07:28 -04:00
handleSwitchSubmit(buf) {
this.dismissDialog();
if (buf) {
this.switchBuffer(buf);
}
}
handleWindowFocus() {
if (this.state.activeBuffer) {
// TODO: only do this if scrolled at the bottom
this.markBufferAsRead(this.state.activeBuffer);
}
// When the user focuses gamja, send a PING to make sure we detect any
// network errors ASAP
let now = new Date();
if (this.lastFocusPingDate && now.getTime() - this.lastFocusPingDate.getTime() < 15 * 1000) {
return;
}
this.lastFocusPingDate = now;
for (let client of this.clients.values()) {
client.send({ command: "PING", params: ["gamja"] });
}
}
componentDidMount() {
this.baseTitle = document.title;
2020-07-23 03:58:05 -04:00
setupKeybindings(this);
window.addEventListener("focus", this.handleWindowFocus);
}
componentWillUnmount() {
document.title = this.baseTitle;
window.removeEventListener("focus", this.handleWindowFocus);
}
render() {
if (this.state.loading) {
2022-09-12 07:41:23 -04:00
let error = null;
if (this.state.error) {
error = html`<form><p class="error-text">${this.state.error}</p></form>`;
}
return html`<section id="connect">${error}</section>`;
}
2021-06-10 12:11:11 -04:00
let activeBuffer = null, activeServer = null, activeBouncerNetwork = null;
if (this.state.buffers.get(this.state.activeBuffer)) {
activeBuffer = this.state.buffers.get(this.state.activeBuffer);
activeServer = this.state.servers.get(activeBuffer.server);
2021-03-08 12:15:04 -05:00
let bouncerNetID = activeServer.bouncerNetID;
2021-03-10 05:48:58 -05:00
if (bouncerNetID) {
activeBouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
}
}
let activeClient = null;
if (activeBuffer) {
activeClient = this.clients.get(activeBuffer.server);
}
if (this.state.connectForm) {
2021-06-10 12:11:11 -04:00
let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
2021-01-21 13:01:50 -05:00
return html`
<section id="connect">
<${ConnectForm}
error=${this.state.error}
params=${this.state.connectParams}
auth=${this.config.server.auth}
connecting=${connecting}
onSubmit=${this.handleConnectSubmit}
/>
2021-01-21 13:01:50 -05:00
</section>
`;
}
2021-06-10 12:11:11 -04:00
let bufferHeader = null;
2020-06-26 06:08:14 -04:00
if (activeBuffer) {
let activeUser = null;
if (activeBuffer.type == BufferType.NICK) {
activeUser = activeServer.users.get(activeBuffer.name);
}
2020-06-26 09:16:07 -04:00
bufferHeader = html`
<section id="buffer-header">
<${BufferHeader}
buffer=${activeBuffer}
server=${activeServer}
user=${activeUser}
2021-03-10 05:48:58 -05:00
bouncerNetwork=${activeBouncerNetwork}
2021-05-31 22:39:35 -04:00
onChannelClick=${this.handleChannelClick}
onClose=${() => this.close(activeBuffer)}
onJoin=${() => this.handleJoinClick(activeBuffer)}
2021-12-07 07:39:02 -05:00
onReconnect=${() => this.reconnect()}
2021-03-08 12:15:04 -05:00
onAddNetwork=${this.handleAddNetworkClick}
onManageNetwork=${() => this.handleManageNetworkClick(activeBuffer.server)}
onOpenSettings=${this.handleOpenSettingsClick}
/>
</section>
`;
}
2021-06-10 12:11:11 -04:00
let memberList = null;
2020-06-26 08:32:56 -04:00
if (activeBuffer && activeBuffer.type == BufferType.CHANNEL) {
memberList = html`
<section
id="member-list"
class=${this.state.openPanels.memberList ? "expand" : ""}
>
<button
class="expander"
onClick=${this.toggleMemberList}
>
<span></span>
<span></span>
</button>
<section>
<section id="member-list-header">
${activeBuffer.members.size} users
</section>
<${MemberList}
members=${activeBuffer.members}
users=${activeServer.users}
onNickClick=${this.handleNickClick}
/>
</section>
2020-06-26 08:32:56 -04:00
</section>
`;
}
2021-06-10 12:11:11 -04:00
let dialog = null;
let dialogData = this.state.dialogData || {};
let dialogBody;
2021-03-08 10:23:16 -05:00
switch (this.state.dialog) {
2021-03-09 13:10:22 -05:00
case "network":
let isNew = !dialogData.id;
let title = isNew ? "Add network" : "Edit network";
2021-03-08 12:15:04 -05:00
dialog = html`
2021-07-04 15:41:36 -04:00
<${Dialog} title=${title} onDismiss=${this.dismissDialog}>
2021-03-09 13:10:22 -05:00
<${NetworkForm}
onSubmit=${this.handleNetworkSubmit}
onRemove=${this.handleNetworkRemove}
params=${dialogData.params}
autojoin=${dialogData.autojoin}
isNew=${isNew}
2021-03-09 13:10:22 -05:00
/>
2021-03-08 12:15:04 -05:00
</>
`;
break;
case "help":
dialog = html`
2021-07-04 15:41:36 -04:00
<${Dialog} title="Help" onDismiss=${this.dismissDialog}>
<${Help}/>
</>
`;
break;
2021-03-08 10:23:16 -05:00
case "join":
dialog = html`
2021-07-04 15:41:36 -04:00
<${Dialog} title="Join channel" onDismiss=${this.dismissDialog}>
<${JoinForm} channel=${dialogData.channel} onSubmit=${this.handleJoinSubmit}/>
2021-03-08 10:23:16 -05:00
</>
`;
break;
case "auth":
2021-11-30 10:05:08 -05:00
if (dialogData.loading) {
dialogBody = html`<p>Logging in…</p>`;
} else {
dialogBody = html`
<${AuthForm} username=${dialogData.username} onSubmit=${this.handleAuthSubmit}/>
`;
}
dialog = html`
<${Dialog} title="Login to ${getServerName(activeServer, activeBouncerNetwork)}" onDismiss=${this.dismissDialog}>
2021-11-30 10:05:08 -05:00
${dialogBody}
</>
`;
break;
case "register":
if (dialogData.loading) {
dialogBody = html`<p>Creating account…</p>`;
} else {
dialogBody = html`
<${RegisterForm} emailRequired=${dialogData.emailRequired} onSubmit=${this.handleRegisterSubmit}/>
`;
}
dialog = html`
<${Dialog} title="Register a new ${getServerName(activeServer, activeBouncerNetwork)} account" onDismiss=${this.dismissDialog}>
${dialogBody}
</>
`;
break;
case "verify":
if (dialogData.loading) {
dialogBody = html`<p>Verifying account…</p>`;
} else {
dialogBody = html`
<${VerifyForm} account=${dialogData.account} message=${dialogData.message} onSubmit=${this.handleVerifySubmit}/>
`;
}
dialog = html`
<${Dialog} title="Verify ${getServerName(activeServer, activeBouncerNetwork)} account" onDismiss=${this.dismissDialog}>
${dialogBody}
</>
`;
break;
case "settings":
dialog = html`
<${Dialog} title="Settings" onDismiss=${this.dismissDialog}>
<${SettingsForm}
settings=${this.state.settings}
showProtocolHandler=${dialogData.showProtocolHandler}
onChange=${this.handleSettingsChange}
onDisconnect=${this.handleSettingsDisconnect}
onClose=${this.dismissDialog}
/>
</>
`;
break;
2023-06-08 09:07:28 -04:00
case "switch":
dialog = html`
<${Dialog} title="Switch to a channel or user" onDismiss=${this.dismissDialog}>
<${SwitcherForm}
buffers=${this.state.buffers}
servers=${this.state.servers}
bouncerNetworks=${this.state.bouncerNetworks}
onSubmit=${this.handleSwitchSubmit}/>
</>
`;
break;
2021-03-08 10:23:16 -05:00
}
2021-06-10 12:11:11 -04:00
let error = null;
2021-03-08 09:05:43 -05:00
if (this.state.error) {
error = html`
<div id="error-msg">
${this.state.error}
${" "}
<button onClick=${this.handleDismissError}>×</button>
</div>
2021-03-08 09:05:43 -05:00
`;
}
2021-06-10 12:11:11 -04:00
let composerReadOnly = false;
if (activeServer && activeServer.status !== ServerStatus.REGISTERED) {
composerReadOnly = true;
}
2022-02-21 09:26:12 -05:00
let commandOnly = false;
let privmsgMaxLen;
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
2022-02-21 09:26:12 -05:00
commandOnly = true;
} else if (activeBuffer) {
let client = this.clients.get(activeBuffer.server);
privmsgMaxLen = irc.getMaxPrivmsgLen(client.isupport, client.nick, activeBuffer.name);
}
let app = html`
<section
id="buffer-list"
class=${this.state.openPanels.bufferList ? "expand" : ""}
>
<${BufferList}
buffers=${this.state.buffers}
servers=${this.state.servers}
bouncerNetworks=${this.state.bouncerNetworks}
activeBuffer=${this.state.activeBuffer}
onBufferClick=${this.handleBufferListClick}
2021-10-17 13:33:02 -04:00
onBufferClose=${this.handleBufferListClose}
/>
<button
class="expander"
onClick=${this.toggleBufferList}
>
<span></span>
<span></span>
</button>
</section>
2020-06-26 09:16:07 -04:00
${bufferHeader}
<${ScrollManager}
target=${this.buffer}
stickTo=".logline"
scrollKey=${this.state.activeBuffer}
onScrollTop=${this.handleBufferScrollTop}
>
<section id="buffer" ref=${this.buffer} tabindex="-1">
2021-05-31 22:39:35 -04:00
<${Buffer}
buffer=${activeBuffer}
server=${activeServer}
bouncerNetwork=${activeBouncerNetwork}
settings=${this.state.settings}
2021-05-31 22:39:35 -04:00
onChannelClick=${this.handleChannelClick}
onNickClick=${this.handleNickClick}
onAuthClick=${() => this.handleAuthClick(activeBuffer.server)}
onRegisterClick=${() => this.handleRegisterClick(activeBuffer.server)}
onVerifyClick=${this.handleVerifyClick}
/>
</section>
</>
2020-06-26 08:32:56 -04:00
${memberList}
<${Composer}
ref=${this.composer}
client=${activeClient}
readOnly=${composerReadOnly}
onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete}
commandOnly=${commandOnly}
maxLen=${privmsgMaxLen}
/>
2021-03-08 10:23:16 -05:00
${dialog}
2021-03-08 09:05:43 -05:00
${error}
`;
return html`
<${SettingsContext.Provider} value=${this.state.settings}>
${app}
</>
`;
}
}