gamja/components/app.js

1385 lines
34 KiB
JavaScript
Raw Normal View History

import * as irc from "../lib/irc.js";
import Client from "../lib/client.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 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, NetworkStatus, Unread } 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 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 {};
});
2020-07-15 12:21:09 -04:00
const CHATHISTORY_MAX_SIZE = 4000;
2020-06-28 09:13:06 -04:00
var messagesCount = 0;
function parseQueryString() {
var query = window.location.search.substring(1);
var params = {};
query.split('&').forEach((s) => {
if (!s) {
return;
}
var pair = s.split('=');
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || "");
});
return params;
}
2020-06-29 03:06:47 -04:00
/* Insert a message in an immutable list of sorted messages. */
function insertMessage(list, msg) {
if (list.length == 0) {
return [msg];
} else if (list[list.length - 1].tags.time <= msg.tags.time) {
return list.concat(msg);
}
var insertBefore = -1;
for (var i = 0; i < list.length; i++) {
var other = list[i];
if (msg.tags.time < other.tags.time) {
insertBefore = i;
break;
}
}
console.assert(insertBefore >= 0, "");
list = [ ...list ];
list.splice(insertBefore, 0, msg);
return list;
}
2020-07-15 12:21:09 -04:00
function debounce(f, delay) {
var timeout = null;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
f(...args);
}, delay);
};
}
2021-03-09 06:04:12 -05:00
function isServerBuffer(buf) {
return buf.type == BufferType.SERVER;
}
2021-01-22 04:37:58 -05:00
2021-03-09 06:04:12 -05:00
/* Returns 1 if a should appear after b, -1 if a should appear before b, or
* 0 otherwise. */
function compareBuffers(a, b) {
if (a.network != b.network) {
return a.network > b.network ? 1 : -1;
}
2021-03-09 06:04:12 -05:00
if (isServerBuffer(a) != isServerBuffer(b)) {
return isServerBuffer(b) ? 1 : -1;
}
2021-03-09 06:04:12 -05:00
if (a.name != b.name) {
return a.name > b.name ? 1 : -1;
}
return 0;
}
2021-01-21 13:01:50 -05:00
function updateState(state, updater) {
var updated;
if (typeof updater === "function") {
updated = updater(state, state);
} else {
updated = updater;
}
if (state === updated || !updated) {
return;
}
return { ...state, ...updated };
}
function getActiveNetworkID(state) {
var buf = state.buffers.get(state.activeBuffer);
if (!buf) {
return null;
}
return buf.network;
}
2021-01-21 16:02:53 -05:00
function getBuffer(state, id) {
switch (typeof id) {
case "number":
return state.buffers.get(id);
case "object":
2021-01-21 16:15:33 -05:00
if (id.id) {
return state.buffers.get(id.id);
}
var netID = id.network, name = id.name;
if (!netID) {
netID = getActiveNetworkID(state);
}
var cm = irc.CaseMapping.RFC1459;
var network = state.networks.get(netID);
if (network) {
cm = irc.CaseMapping.byName(network.isupport.get("CASEMAPPING")) || cm;
2021-01-21 16:02:53 -05:00
}
var nameCM = cm(name);
2021-01-21 16:02:53 -05:00
for (var buf of state.buffers.values()) {
if (buf.network === netID && cm(buf.name) === nameCM) {
2021-01-21 16:02:53 -05:00
return buf;
}
2021-01-21 14:41:44 -05:00
}
2021-01-21 16:02:53 -05:00
return null;
default:
throw new Error("Invalid buffer ID type: " + (typeof id));
2021-01-21 14:41:44 -05:00
}
}
export default class App extends Component {
state = {
connectParams: {
serverURL: null,
serverPass: null,
username: null,
realname: null,
nick: null,
saslPlain: null,
autoconnect: false,
autojoin: [],
},
2021-01-21 13:01:50 -05:00
networks: new Map(),
buffers: new Map(),
bouncerNetworks: new Map(),
activeBuffer: null,
2021-03-08 10:23:16 -05:00
dialog: null,
error: null,
openPanels: {
bufferList: false,
memberList: false,
},
};
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-01-22 04:49:08 -05:00
lastNetworkID = 0;
2021-01-21 14:41:44 -05:00
lastBufferID = 0;
2021-05-25 06:40:33 -04:00
switchToChannel = 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);
this.toggleBufferList = this.toggleBufferList.bind(this);
this.toggleMemberList = this.toggleMemberList.bind(this);
this.handleComposerSubmit = this.handleComposerSubmit.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-03-08 10:23:16 -05:00
this.handleDialogDismiss = this.handleDialogDismiss.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.dismissError = this.dismissError.bind(this);
2020-07-15 12:21:09 -04:00
this.saveReceipts = debounce(this.saveReceipts.bind(this), 500);
this.receipts = store.receipts.load();
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
*/
handleConfig(config) {
2021-05-27 12:17:41 -04:00
this.config = config;
var host = window.location.host || "localhost:8080";
var proto = "wss:";
if (window.location.protocol != "https:") {
proto = "ws:";
}
var path = window.location.pathname || "/";
if (!window.location.host) {
path = "/";
}
var connectParams = {
serverURL: proto + "//" + host + path + "socket",
};
if (config.server) {
connectParams.serverURL = config.server.url;
if (Array.isArray(config.server.autojoin)) {
connectParams.autojoin = config.server.autojoin;
2020-07-01 06:25:57 -04:00
} else {
connectParams.autojoin = [config.server.autojoin];
}
}
var autoconnect = store.autoconnect.load();
if (autoconnect) {
connectParams = {
...connectParams,
...autoconnect,
autoconnect: true,
};
}
var queryParams = parseQueryString();
if (queryParams.server) {
if (queryParams.server.startsWith("/")) {
connectParams.serverURL = proto + "//" + host + queryParams.server;
} else {
connectParams.serverURL = queryParams.server;
}
}
if (queryParams.nick) {
connectParams.nick = queryParams.nick;
}
if (queryParams.channels) {
connectParams.autojoin = queryParams.channels.split(",");
2020-07-15 12:21:09 -04:00
}
if (window.location.hash) {
connectParams.autojoin = window.location.hash.split(",");
}
this.setState((state) => {
return { connectParams: { ...state.connectParams, ...connectParams } };
});
if (connectParams.autoconnect) {
this.connect(connectParams);
}
}
dismissError(event) {
event.preventDefault();
this.setState({ error: null });
}
2021-01-21 13:01:50 -05:00
setNetworkState(id, updater, callback) {
this.setState((state) => {
var net = state.networks.get(id);
if (!net) {
return;
}
var updated = updateState(net, updater);
2021-01-21 14:41:44 -05:00
if (!updated) {
return;
}
2021-01-21 13:01:50 -05:00
var networks = new Map(state.networks);
networks.set(id, updated);
return { networks };
}, callback);
}
2021-01-21 14:41:44 -05:00
setBufferState(id, updater, callback) {
this.setState((state) => {
2021-01-21 16:02:53 -05:00
var buf = getBuffer(state, id);
if (!buf) {
return;
}
2021-01-21 13:01:50 -05:00
var updated = updateState(buf, updater);
2021-01-21 14:41:44 -05:00
if (!updated) {
return;
}
var buffers = new Map(state.buffers);
2021-01-21 14:41:44 -05:00
buffers.set(buf.id, updated);
return { buffers };
}, callback);
}
2021-01-21 14:41:44 -05:00
createBuffer(netID, name, callback) {
var id = null;
this.setState((state) => {
2021-01-21 16:02:53 -05:00
if (getBuffer(state, { network: netID, name })) {
return;
}
2021-01-21 14:41:44 -05:00
this.lastBufferID++;
id = this.lastBufferID;
2020-06-26 04:35:38 -04:00
var type;
if (name == SERVER_BUFFER) {
type = BufferType.SERVER;
} else if (this.isChannel(name)) {
type = BufferType.CHANNEL;
} else {
type = BufferType.NICK;
}
var client = this.clients.get(netID);
var cm = client ? client.cm : irc.CaseMapping.RFC1459;
var bufferList = Array.from(state.buffers.values());
bufferList.push({
2021-01-21 14:41:44 -05:00
id,
2020-06-26 04:35:38 -04:00
name,
type,
2021-01-21 13:27:04 -05:00
network: netID,
2020-06-26 06:08:14 -04:00
serverInfo: null, // if server
topic: null, // if channel
members: new irc.CaseMapMap(null, cm), // if channel
who: null, // if nick
offline: false, // if nick
messages: [],
2020-06-24 10:56:28 -04:00
unread: Unread.NONE,
});
bufferList = bufferList.sort(compareBuffers);
2021-01-21 14:41:44 -05:00
var buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
return { buffers };
2021-01-21 14:41:44 -05:00
}, () => {
if (callback) {
callback(id);
}
});
}
2021-01-21 16:15:33 -05:00
switchBuffer(id) {
2021-01-21 14:41:44 -05:00
var buf;
this.setState((state) => {
2021-01-21 16:15:33 -05:00
buf = getBuffer(state, id);
2021-01-21 14:41:44 -05:00
if (!buf) {
return;
}
return { activeBuffer: buf.id };
}, () => {
2021-01-21 16:15:33 -05:00
if (!buf) {
return;
}
var lastReadReceipt = this.getReceipt(buf.name, ReceiptType.READ);
// TODO: only mark as read if user scrolled at the bottom
this.setBufferState(buf.id, {
unread: Unread.NONE,
lastReadReceipt,
});
if (this.composer.current) {
this.composer.current.focus();
}
2020-07-15 12:21:09 -04:00
2021-01-21 16:15:33 -05:00
if (buf.messages.length == 0) {
2020-07-15 12:21:09 -04:00
return;
}
var lastMsg = buf.messages[buf.messages.length - 1];
2021-01-21 16:15:33 -05:00
this.setReceipt(buf.name, ReceiptType.READ, lastMsg);
2020-07-15 12:21:09 -04:00
});
}
saveReceipts() {
store.receipts.put(this.receipts);
2020-07-15 12:21:09 -04:00
}
getReceipt(target, type) {
var receipts = this.receipts.get(target);
if (!receipts) {
return undefined;
}
return receipts[type];
}
hasReceipt(target, type, msg) {
var receipt = this.getReceipt(target, type);
return receipt && msg.tags.time <= receipt.time;
}
setReceipt(target, type, msg) {
var receipt = this.getReceipt(target, type);
if (this.hasReceipt(target, type, msg)) {
return;
}
this.receipts.set(target, {
...this.receipts.get(target),
[type]: { time: msg.tags.time },
});
2020-07-15 12:21:09 -04:00
this.saveReceipts();
}
2021-01-21 13:27:04 -05:00
addMessage(netID, bufName, msg) {
2021-01-22 11:36:53 -05:00
var client = this.clients.get(netID);
2020-06-28 09:13:06 -04:00
msg.key = messagesCount;
messagesCount++;
2021-01-22 11:36:53 -05:00
msg.isHighlight = irc.isHighlight(msg, client.nick);
if (!msg.tags) {
msg.tags = {};
}
2020-06-29 03:06:47 -04:00
if (!msg.tags.time) {
msg.tags.time = irc.formatDate(new Date());
}
2020-07-15 12:21:09 -04:00
var isDelivered = this.hasReceipt(bufName, ReceiptType.DELIVERED, msg);
var isRead = this.hasReceipt(bufName, ReceiptType.READ, msg);
// TODO: messages coming from infinite scroll shouldn't trigger notifications
2020-06-24 11:46:43 -04:00
var msgUnread = Unread.NONE;
2020-07-15 12:21:09 -04:00
if ((msg.command == "PRIVMSG" || msg.command == "NOTICE") && !isRead) {
2020-06-29 05:50:42 -04:00
var target = msg.params[0];
2020-06-29 05:08:47 -04:00
var text = msg.params[1];
2020-06-29 05:50:42 -04:00
var 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-01-22 11:36:53 -05:00
} else if (target == client.nick) {
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 && window.Notification && Notification.permission === "granted" && !isDelivered && !irc.parseCTCP(msg)) {
2020-06-29 05:50:42 -04:00
var title = "New " + kind + " from " + msg.prefix.name;
if (this.isChannel(target)) {
title += " in " + target;
}
var notif = new Notification(title, {
body: stripANSI(text),
2020-06-29 05:50:42 -04:00
requireInteraction: true,
});
notif.addEventListener("click", () => {
// TODO: scroll to message
2021-01-21 16:15:33 -05:00
this.switchBuffer({ network: netID, name: target });
2020-06-29 05:50:42 -04:00
});
}
2020-06-24 10:56:28 -04:00
}
if (!client.isMyNick(msg.prefix.name) && (msg.command != "PART" && msg.comand != "QUIT")) {
2021-01-21 13:27:04 -05:00
this.createBuffer(netID, bufName);
}
2020-07-15 12:21:09 -04:00
this.setReceipt(bufName, ReceiptType.DELIVERED, msg);
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: bufName }, (buf) => {
2020-07-15 12:21:09 -04:00
// TODO: set unread if scrolled up
2020-06-24 10:56:28 -04:00
var unread = buf.unread;
var lastReadReceipt = buf.lastReadReceipt;
if (this.state.activeBuffer != buf.id) {
2020-06-24 11:46:43 -04:00
unread = Unread.union(unread, msgUnread);
2020-07-15 12:21:09 -04:00
} else {
this.setReceipt(bufName, ReceiptType.READ, msg);
lastReadReceipt = this.getReceipt(bufName, ReceiptType.READ);
2020-06-24 10:56:28 -04:00
}
2020-06-29 03:06:47 -04:00
var messages = insertMessage(buf.messages, msg);
return { messages, unread, lastReadReceipt };
});
}
2021-01-22 12:44:06 -05:00
connect(params) {
this.lastNetworkID++;
var netID = this.lastNetworkID;
2021-01-12 04:35:38 -05:00
2021-01-21 13:01:50 -05:00
this.setState((state) => {
var networks = new Map(state.networks);
networks.set(netID, {
id: netID,
2021-01-22 12:29:22 -05:00
status: NetworkStatus.CONNECTING,
2021-01-22 05:34:04 -05:00
isupport: new Map(),
2021-01-21 13:01:50 -05:00
});
return { networks };
});
this.setState({ connectParams: params });
2021-01-22 15:00:06 -05:00
var client = new Client(params);
2021-01-22 11:36:53 -05:00
this.clients.set(netID, client);
2021-01-22 12:29:22 -05:00
client.addEventListener("status", () => {
2021-01-22 12:44:06 -05:00
this.setNetworkState(netID, { status: client.status });
2021-01-21 13:01:50 -05:00
});
2021-01-22 11:36:53 -05:00
client.addEventListener("message", (event) => {
2021-01-21 13:01:50 -05:00
this.handleMessage(netID, event.detail.message);
});
2021-01-22 11:36:53 -05:00
client.addEventListener("error", (event) => {
this.setState({ error: event.detail });
});
2021-01-21 13:27:04 -05:00
this.createBuffer(netID, SERVER_BUFFER);
if (!this.state.activeBuffer) {
this.switchBuffer({ network: netID, name: SERVER_BUFFER });
}
2021-05-25 06:40:33 -04:00
if (params.autojoin.length > 0) {
this.switchToChannel = params.autojoin[0];
}
2021-05-27 12:17:41 -04:00
if (this.config.server && typeof this.config.server.ping === "number") {
client.setPingInterval(this.config.server.ping);
2021-05-27 12:17:41 -04:00
}
}
2021-01-21 13:01:50 -05:00
disconnect(netID) {
if (!netID) {
netID = getActiveNetworkID(this.state);
}
2021-01-22 11:36:53 -05:00
var client = this.clients.get(netID);
if (client) {
2021-03-10 04:58:25 -05:00
this.clients.delete(netID);
2021-01-22 12:29:22 -05:00
client.disconnect();
2021-01-12 04:35:38 -05:00
}
}
2021-01-21 13:01:50 -05:00
reconnect(netID) {
if (!netID) {
netID = getActiveNetworkID(this.state);
}
2021-01-22 12:44:06 -05:00
var client = this.clients.get(netID);
if (client) {
client.reconnect();
}
2021-01-12 04:35:38 -05:00
}
networkFromBouncerNetwork(bouncerNetworkID) {
for (var [id, client] of this.clients) {
if (client.params.bouncerNetwork === bouncerNetworkID) {
return id;
}
}
return null;
}
2021-01-21 13:01:50 -05:00
handleMessage(netID, msg) {
2021-01-22 11:36:53 -05:00
var client = this.clients.get(netID);
switch (msg.command) {
case irc.RPL_WELCOME:
if (this.state.connectParams.autojoin.length > 0) {
2021-01-22 11:36:53 -05:00
client.send({
command: "JOIN",
params: [this.state.connectParams.autojoin.join(",")],
});
}
break;
2020-06-26 06:08:14 -04:00
case irc.RPL_MYINFO:
// TODO: parse available modes
var serverInfo = {
name: msg.params[1],
version: msg.params[2],
};
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: SERVER_BUFFER }, { serverInfo });
2020-06-26 06:08:14 -04:00
break;
2021-01-22 05:34:04 -05:00
case irc.RPL_ISUPPORT:
this.setNetworkState(netID, (network) => {
2021-05-11 10:03:16 -04:00
return { isupport: new Map(client.isupport) };
2021-01-22 05:34:04 -05:00
});
this.setState((state) => {
var buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.network != netID) {
return;
}
var members = new irc.CaseMapMap(buf.members, client.cm);
buffers.set(buf.id, { ...buf, members });
});
return { buffers };
});
2021-01-22 05:34:04 -05:00
break;
2020-08-03 12:59:54 -04:00
case irc.RPL_NOTOPIC:
var channel = msg.params[1];
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: channel }, { topic: null });
2020-08-03 12:59:54 -04:00
break;
case irc.RPL_TOPIC:
var channel = msg.params[1];
var topic = msg.params[2];
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: channel }, { topic });
break;
case irc.RPL_TOPICWHOTIME:
// Ignore
break;
case irc.RPL_NAMREPLY:
var channel = msg.params[2];
var membersList = msg.params[3].split(" ");
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: channel }, (buf) => {
var members = new irc.CaseMapMap(buf.members);
membersList.forEach((s) => {
var member = irc.parseMembership(s);
members.set(member.nick, member.prefix);
});
return { members };
});
break;
case irc.RPL_ENDOFNAMES:
break;
case irc.RPL_WHOREPLY:
var last = msg.params[msg.params.length - 1];
var who = {
username: msg.params[2],
hostname: msg.params[3],
server: msg.params[4],
nick: msg.params[5],
away: msg.params[6] == 'G', // H for here, G for gone
realname: last.slice(last.indexOf(" ") + 1),
};
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: who.nick }, { who, offline: false });
break;
case irc.RPL_ENDOFWHO:
var target = msg.params[1];
if (!this.isChannel(target) && target.indexOf("*") < 0) {
// Not a channel nor a mask, likely a nick
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: target }, (buf) => {
// TODO: mark user offline if we have old WHO info but this
// WHO reply is empty
if (buf.who) {
return;
}
return { offline: true };
});
}
break;
case "MODE":
var target = msg.params[0];
if (this.isChannel(target)) {
this.addMessage(netID, target, msg);
}
break;
case "NOTICE":
case "PRIVMSG":
var target = msg.params[0];
if (client.isMyNick(target)) {
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
target = SERVER_BUFFER;
} else {
target = msg.prefix.name;
}
}
2021-01-21 13:27:04 -05:00
this.addMessage(netID, target, msg);
break;
case "JOIN":
var channel = msg.params[0];
2021-01-21 13:27:04 -05:00
this.createBuffer(netID, channel);
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: channel }, (buf) => {
var members = new irc.CaseMapMap(buf.members);
members.set(msg.prefix.name, null);
return { members };
});
if (!client.isMyNick(msg.prefix.name)) {
2021-01-21 13:27:04 -05:00
this.addMessage(netID, channel, msg);
}
2021-05-25 06:40:33 -04:00
if (channel == this.switchToChannel) {
2021-01-21 16:15:33 -05:00
this.switchBuffer({ network: netID, name: channel });
2021-05-25 06:40:33 -04:00
this.switchToChannel = null;
}
2020-07-15 12:21:09 -04:00
var receipt = this.getReceipt(channel, ReceiptType.READ);
if (client.isMyNick(msg.prefix.name) && receipt && client.enabledCaps["draft/chathistory"] && client.enabledCaps["server-time"]) {
2020-07-15 12:21:09 -04:00
var after = receipt;
var before = { time: msg.tags.time || irc.formatDate(new Date()) };
client.fetchHistoryBetween(channel, after, before, CHATHISTORY_MAX_SIZE).catch((err) => {
this.setState({ error: "Failed to fetch history: " + err });
2020-07-15 12:21:09 -04:00
this.receipts.delete(channel);
this.saveReceipts();
});
}
break;
case "PART":
var channel = msg.params[0];
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: channel }, (buf) => {
var members = new irc.CaseMapMap(buf.members);
members.delete(msg.prefix.name);
return { members };
});
2021-01-21 13:27:04 -05:00
this.addMessage(netID, channel, msg);
2020-07-15 12:21:09 -04:00
if (client.isMyNick(msg.prefix.name)) {
this.receipts.delete(channel);
this.saveReceipts();
}
break;
case "KICK":
var channel = msg.params[0];
var user = msg.params[1];
this.setBufferState({ network: netID, name: channel }, (buf) => {
var members = new irc.CaseMapMap(buf.members);
members.delete(user);
return { members };
});
this.addMessage(netID, channel, msg);
if (client.isMyNick(msg.prefix.name)) {
2020-07-15 12:21:09 -04:00
this.receipts.delete(channel);
this.saveReceipts();
}
break;
2020-07-08 12:39:24 -04:00
case "QUIT":
var affectedBuffers = [];
this.setState((state) => {
var buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.network != netID) {
return;
}
if (!buf.members.has(msg.prefix.name) && client.cm(buf.name) !== client.cm(msg.prefix.name)) {
2020-07-08 12:39:24 -04:00
return;
}
var members = new irc.CaseMapMap(buf.members);
2020-07-08 12:39:24 -04:00
members.delete(msg.prefix.name);
var offline = client.cm(buf.name) === client.cm(msg.prefix.name);
2021-01-21 16:15:33 -05:00
buffers.set(buf.id, { ...buf, members, offline });
2020-07-08 12:39:24 -04:00
affectedBuffers.push(buf.name);
});
return { buffers };
});
2021-01-21 13:27:04 -05:00
affectedBuffers.forEach((name) => this.addMessage(netID, name, msg));
2020-07-08 12:39:24 -04:00
break;
case "NICK":
var newNick = msg.params[0];
var affectedBuffers = [];
this.setState((state) => {
var buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.network != netID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
return;
}
var members = new irc.CaseMapMap(buf.members);
members.set(newNick, members.get(msg.prefix.name));
members.delete(msg.prefix.name);
2021-01-21 16:15:33 -05:00
buffers.set(buf.id, { ...buf, members });
affectedBuffers.push(buf.name);
});
return { buffers };
});
2021-01-21 13:27:04 -05:00
affectedBuffers.forEach((name) => this.addMessage(netID, name, msg));
break;
2021-05-25 14:22:21 -04:00
case "SETNAME":
this.setBufferState({ network: netID, name: msg.prefix.name }, (buf) => {
var who = { ...buf.who, realname: msg.params[0] };
return { who }
});
break;
case "TOPIC":
var channel = msg.params[0];
var topic = msg.params[1];
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: channel }, { topic });
2021-01-21 13:27:04 -05:00
this.addMessage(netID, channel, msg);
break;
2020-06-26 06:45:27 -04:00
case "AWAY":
var awayMessage = msg.params[0];
2021-03-09 03:16:32 -05:00
this.setBufferState({ network: netID, name: msg.prefix.name }, (buf) => {
2020-06-26 06:45:27 -04:00
var who = { ...buf.who, away: !!awayMessage };
return { who };
});
break;
case "BOUNCER":
if (msg.params[0] !== "NETWORK") {
break; // We're only interested in network updates
}
if (client.isupport.has("BOUNCER_NETID")) {
// This cn happen if the user has specified a network to bind
// to via other means, e.g. "<username>/<network>".
break;
}
var id = msg.params[1];
var attrs = null;
if (msg.params[2] !== "*") {
attrs = irc.parseTags(msg.params[2]);
}
var isNew = false;
this.setState((state) => {
var bouncerNetworks = new Map(state.bouncerNetworks);
if (!attrs) {
bouncerNetworks.delete(id);
} else {
var prev = bouncerNetworks.get(id);
isNew = prev === undefined;
attrs = { ...prev, ...attrs };
bouncerNetworks.set(id, attrs);
}
return { bouncerNetworks };
}, () => {
if (!attrs) {
var netID = this.networkFromBouncerNetwork(id);
if (netID) {
this.close({ network: netID, name: SERVER_BUFFER });
}
} else if (isNew) {
this.connect({
...client.params,
bouncerNetwork: id,
});
}
});
break;
case "CAP":
case "AUTHENTICATE":
2020-07-01 06:12:56 -04:00
case "PING":
2021-05-27 12:17:41 -04:00
case "PONG":
2020-07-15 12:21:09 -04:00
case "BATCH":
case "TAGMSG":
// Ignore these
break;
default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
var description = msg.params[msg.params.length - 1];
this.setState({ error: description });
}
2021-01-21 13:27:04 -05:00
this.addMessage(netID, SERVER_BUFFER, msg);
}
}
handleConnectSubmit(connectParams) {
this.setState({ error: null });
if (connectParams.autoconnect) {
store.autoconnect.put(connectParams);
} else {
store.autoconnect.put(null);
}
2021-01-22 12:44:06 -05:00
this.connect(connectParams);
}
2020-06-25 12:45:41 -04:00
handleNickClick(nick) {
this.open(nick);
}
2020-06-24 09:52:33 -04:00
isChannel(name) {
// TODO: use the ISUPPORT token if available
return irc.STD_CHANNEL_TYPES.indexOf(name[0]) >= 0;
}
2020-06-25 12:45:41 -04:00
open(target) {
2021-01-22 11:36:53 -05:00
var netID = getActiveNetworkID(this.state);
var client = this.clients.get(netID);
2020-06-25 12:45:41 -04:00
if (this.isChannel(target)) {
this.switchToChannel = target;
2021-01-22 11:36:53 -05:00
client.send({ command: "JOIN", params: [target] });
} else {
2021-01-22 11:36:53 -05:00
client.send({ command: "WHO", params: [target] });
this.createBuffer(netID, target);
this.switchBuffer({ network: netID, name: target });
2020-06-25 12:45:41 -04:00
}
}
2021-01-22 04:26:53 -05:00
close(id) {
var buf = getBuffer(this.state, id);
if (!buf) {
return;
}
switch (buf.type) {
case BufferType.SERVER:
2021-03-10 04:59:39 -05:00
this.setState((state) => {
var buffers = new Map(state.buffers);
for (var [id, b] of state.buffers) {
if (b.network === buf.network) {
buffers.delete(id);
}
}
var activeBuffer = state.activeBuffer;
if (activeBuffer && state.buffers.get(activeBuffer).network === buf.network) {
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
var client = this.clients.get(buf.network);
var disconnectAll = client && !client.params.bouncerNetwork && client.enabledCaps["soju.im/bouncer-networks"];
2021-01-22 04:26:53 -05:00
this.disconnect(buf.network);
2021-03-10 04:59:39 -05:00
2021-01-22 04:41:28 -05:00
this.setState((state) => {
var networks = new Map(state.networks);
networks.delete(buf.network);
return { networks };
});
2021-03-10 04:59:39 -05:00
if (disconnectAll) {
for (var netID of this.clients.keys()) {
this.close({ network: netID, name: SERVER_BUFFER });
}
}
// TODO: only clear local storage if this network is stored there
if (buf.network == 1) {
store.autoconnect.put(null);
}
2021-01-22 04:26:53 -05:00
break;
case BufferType.CHANNEL:
2021-01-22 11:36:53 -05:00
var client = this.clients.get(buf.network);
client.send({ command: "PART", params: [buf.name] });
2021-01-22 04:26:53 -05:00
// fallthrough
case BufferType.NICK:
this.switchBuffer({ name: SERVER_BUFFER });
this.setState((state) => {
var 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-01-22 11:36:53 -05:00
this.receipts.delete(buf.name);
2021-01-22 04:26:53 -05:00
this.saveReceipts();
break;
}
}
executeCommand(s) {
var parts = s.split(" ");
2020-07-13 11:22:24 -04:00
var name = parts[0].toLowerCase().slice(1);
var args = parts.slice(1);
2020-07-13 11:22:24 -04:00
var cmd = commands[name];
if (!cmd) {
this.setState({ error: `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.setState({ error: error.message });
}
}
2020-06-28 03:29:39 -04:00
privmsg(target, text) {
if (target == SERVER_BUFFER) {
this.setState({ error: "Cannot send message in server buffer" });
2020-06-28 03:29:39 -04:00
return;
}
2021-01-22 11:36:53 -05:00
var netID = getActiveNetworkID(this.state);
var client = this.clients.get(netID);
2020-06-28 03:29:39 -04:00
var 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-01-22 11:36:53 -05:00
if (!client.enabledCaps["echo-message"]) {
msg.prefix = { name: client.nick };
this.addMessage(netID, 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-01-21 14:41:44 -05:00
var buf = this.state.buffers.get(this.state.activeBuffer);
if (!buf) {
return;
}
2021-01-21 14:41:44 -05:00
this.privmsg(buf.name, text);
}
handleBufferListClick(id) {
this.switchBuffer(id);
this.closeBufferList();
}
toggleBufferList() {
this.setState((state) => {
var openPanels = {
...state.openPanels,
bufferList: !state.openPanels.bufferList,
};
return { openPanels };
});
}
toggleMemberList() {
this.setState((state) => {
var openPanels = {
...state.openPanels,
memberList: !state.openPanels.memberList,
};
return { openPanels };
});
}
closeBufferList() {
this.setState((state) => {
var openPanels = {
...state.openPanels,
bufferList: false,
};
return { openPanels };
});
}
closeMemberList() {
this.setState((state) => {
var openPanels = {
...state.openPanels,
memberList: false,
};
return { openPanels };
});
}
2021-03-02 10:13:49 -05:00
handleJoinClick(netID) {
2021-03-08 10:23:16 -05:00
this.setState({ dialog: "join", joinDialog: { network: netID } });
}
2021-01-22 11:36:53 -05:00
2021-03-08 10:23:16 -05:00
handleJoinSubmit(data) {
var client = this.clients.get(this.state.joinDialog.network);
this.switchToChannel = data.channel;
2021-03-08 10:23:16 -05:00
client.send({ command: "JOIN", params: [data.channel] });
2021-01-22 11:36:53 -05:00
2021-03-08 10:23:16 -05:00
this.setState({ dialog: null, joinDialog: null });
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();
var repl = null;
for (var item of l) {
if (item.toLowerCase().startsWith(prefix)) {
if (repl) {
return null;
}
repl = item;
}
}
return repl;
2020-06-29 06:36:17 -04:00
}
2020-07-13 11:28:49 -04:00
if (prefix.startsWith("/")) {
var repl = fromList(Object.keys(commands), prefix.slice(1));
if (repl) {
repl = "/" + repl;
2020-06-29 06:36:17 -04:00
}
2020-07-13 11:28:49 -04:00
return repl;
}
2021-01-21 14:41:44 -05:00
var buf = this.state.buffers.get(this.state.activeBuffer);
if (!buf || !buf.members) {
2020-07-13 11:28:49 -04:00
return null;
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() {
this.setState({ dialog: "help" });
}
2020-06-29 03:06:47 -04:00
handleBufferScrollTop() {
2021-01-21 14:41:44 -05:00
var buf = this.state.buffers.get(this.state.activeBuffer);
if (!buf || buf.type == BufferType.SERVER) {
2020-06-29 03:06:47 -04:00
return;
}
2021-01-22 11:36:53 -05:00
var client = this.clients.get(buf.network);
if (!client || !client.enabledCaps["draft/chathistory"] || !client.enabledCaps["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;
}
var before;
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
client.fetchHistoryBefore(buf.name, before, 100).then((result) => {
2021-01-23 06:16:57 -05:00
this.endOfHistory.set(buf.id, !result.more);
2020-07-15 12:21:09 -04:00
});
2020-06-29 03:06:47 -04:00
}
2021-03-08 10:23:16 -05:00
handleDialogDismiss() {
this.setState({ dialog: null });
}
2021-03-08 12:15:04 -05:00
handleAddNetworkClick() {
2021-03-09 13:10:22 -05:00
this.setState({ dialog: "network", networkDialog: null });
2021-03-08 12:15:04 -05:00
}
2021-03-09 13:10:22 -05:00
handleManageNetworkClick(netID) {
var network = this.state.networks.get(netID);
var bouncerNetID = network.isupport.get("BOUNCER_NETID");
var bouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
this.setState({
dialog: "network",
networkDialog: {
id: bouncerNetID,
params: bouncerNetwork,
},
});
}
handleNetworkSubmit(attrs) {
2021-03-08 12:15:04 -05:00
var client = this.clients.values().next().value;
2021-03-09 13:10:22 -05:00
if (this.state.networkDialog && this.state.networkDialog.id) {
if (Object.keys(attrs).length == 0) {
this.setState({ dialog: null });
return;
}
client.send({
command: "BOUNCER",
params: ["CHANGENETWORK", this.state.networkDialog.id, irc.formatTags(attrs)],
});
} else {
attrs = { ...attrs, tls: "1" };
client.send({
command: "BOUNCER",
params: ["ADDNETWORK", irc.formatTags(attrs)],
});
}
this.setState({ dialog: null, networkDialog: null });
}
handleNetworkRemove() {
var client = this.clients.values().next().value;
2021-03-08 12:15:04 -05:00
client.send({
command: "BOUNCER",
2021-03-09 13:10:22 -05:00
params: ["DELNETWORK", this.state.networkDialog.id],
2021-03-08 12:15:04 -05:00
});
2021-03-09 13:10:22 -05:00
this.setState({ dialog: null, networkDialog: null });
2021-03-08 12:15:04 -05:00
}
componentDidMount() {
2020-07-23 03:58:05 -04:00
setupKeybindings(this);
}
render() {
2021-03-10 05:48:58 -05:00
var activeBuffer = null, activeNetwork = null, activeBouncerNetwork = null;
2021-03-08 12:15:04 -05:00
var isBouncer = false;
if (this.state.buffers.get(this.state.activeBuffer)) {
activeBuffer = this.state.buffers.get(this.state.activeBuffer);
activeNetwork = this.state.networks.get(activeBuffer.network);
2021-03-08 12:15:04 -05:00
var activeClient = this.clients.get(activeBuffer.network);
isBouncer = activeClient && activeClient.enabledCaps["soju.im/bouncer-networks"];
2021-03-10 05:48:58 -05:00
var bouncerNetID = activeNetwork.isupport.get("BOUNCER_NETID");
if (bouncerNetID) {
activeBouncerNetwork = this.state.bouncerNetworks.get(bouncerNetID);
}
}
2021-01-22 12:29:22 -05:00
if (!activeNetwork || (activeNetwork.status !== NetworkStatus.REGISTERED && !activeBuffer)) {
// TODO: using key=connectParams trashes the ConnectForm state on update
2021-01-21 13:01:50 -05:00
return html`
<section id="connect">
<${ConnectForm}
error=${this.state.error}
params=${this.state.connectParams}
disabled=${activeNetwork}
onSubmit=${this.handleConnectSubmit}
key=${this.state.connectParams}
/>
2021-01-21 13:01:50 -05:00
</section>
`;
}
2020-06-26 09:16:07 -04:00
var bufferHeader = null;
2020-06-26 06:08:14 -04:00
if (activeBuffer) {
2020-06-26 09:16:07 -04:00
bufferHeader = html`
<section id="buffer-header">
<${BufferHeader}
buffer=${activeBuffer}
network=${activeNetwork}
2021-03-08 12:15:04 -05:00
isBouncer=${isBouncer}
2021-03-10 05:48:58 -05:00
bouncerNetwork=${activeBouncerNetwork}
onClose=${() => this.close(activeBuffer)}
onJoin=${() => this.handleJoinClick(activeBuffer.network)}
2021-03-08 12:15:04 -05:00
onAddNetwork=${this.handleAddNetworkClick}
2021-03-09 13:10:22 -05:00
onManageNetwork=${() => this.handleManageNetworkClick(activeBuffer.network)}
/>
</section>
`;
}
2020-06-26 08:32:56 -04:00
var memberList = null;
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}
onNickClick=${this.handleNickClick}
/>
</section>
2020-06-26 08:32:56 -04:00
</section>
`;
}
2021-03-08 10:23:16 -05:00
var dialog = null;
switch (this.state.dialog) {
2021-03-09 13:10:22 -05:00
case "network":
var title = this.state.networkDialog ? "Edit network" : "Add network";
2021-03-08 12:15:04 -05:00
dialog = html`
2021-03-09 13:10:22 -05:00
<${Dialog} title=${title} onDismiss=${this.handleDialogDismiss}>
<${NetworkForm}
onSubmit=${this.handleNetworkSubmit}
onRemove=${this.handleNetworkRemove}
params=${this.state.networkDialog ? this.state.networkDialog.params : null}
/>
2021-03-08 12:15:04 -05:00
</>
`;
break;
case "help":
dialog = html`
<${Dialog} title="Help" onDismiss=${this.handleDialogDismiss}>
<${Help}/>
</>
`;
break;
2021-03-08 10:23:16 -05:00
case "join":
dialog = html`
<${Dialog} title="Join channel" onDismiss=${this.handleDialogDismiss}>
<${JoinForm} onSubmit=${this.handleJoinSubmit}/>
2021-03-08 10:23:16 -05:00
</>
`;
break;
}
2021-03-08 09:05:43 -05:00
var error = null;
if (this.state.error) {
error = html`
<p id="error-msg">
${this.state.error}
${" "}
<a href="#" onClick=${this.dismissError}>×</a>
</p>
2021-03-08 09:05:43 -05:00
`;
}
return html`
<section
id="buffer-list"
class=${this.state.openPanels.bufferList ? "expand" : ""}
>
<${BufferList}
buffers=${this.state.buffers}
networks=${this.state.networks}
bouncerNetworks=${this.state.bouncerNetworks}
2021-03-08 12:15:04 -05:00
isBouncer=${isBouncer}
activeBuffer=${this.state.activeBuffer}
onBufferClick=${this.handleBufferListClick}
/>
<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}>
2020-06-25 12:45:41 -04:00
<${Buffer} buffer=${activeBuffer} onNickClick=${this.handleNickClick}/>
</section>
</>
2020-06-26 08:32:56 -04:00
${memberList}
<${Composer}
ref=${this.composer}
readOnly=${activeBuffer && activeBuffer.type == BufferType.SERVER}
onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete}
/>
2021-03-08 10:23:16 -05:00
${dialog}
2021-03-08 09:05:43 -05:00
${error}
`;
}
}