thelounge/server/models/chan.ts
Val Lorentz 76098d7e76 Fix incorrect typing of dehydrated networks and channels
Client and ClientManager deal with both 'dehydrated' channels/networks (ie. directly
from JSON configuration) and the 'rehydrated' ones (classes, with socket objects,
message arrays, etc.).

However, because their attributes are similar, both types were used interchangeably,
which becomes an issue when splitting Client's configuration loading into smaller
methods.
2023-03-15 08:40:53 +01:00

345 lines
9 KiB
TypeScript

import _ from "lodash";
import log from "../log";
import Config from "../config";
import User from "./user";
import Msg, {MessageType} from "./msg";
import storage from "../plugins/storage";
import Client from "../client";
import Network from "./network";
import Prefix from "./prefix";
export enum ChanType {
CHANNEL = "channel",
LOBBY = "lobby",
QUERY = "query",
SPECIAL = "special",
}
export enum SpecialChanType {
BANLIST = "list_bans",
INVITELIST = "list_invites",
CHANNELLIST = "list_channels",
IGNORELIST = "list_ignored",
}
export enum ChanState {
PARTED = 0,
JOINED = 1,
}
// eslint-disable-next-line no-use-before-define
export type FilteredChannel = Chan & {
users: [];
totalMessages: number;
};
export type ChanConfig = {
name: string;
key?: string;
muted?: boolean;
type?: string;
};
class Chan {
id: number;
messages: Msg[];
name: string;
key: string;
topic: string;
firstUnread: number;
unread: number;
highlight: number;
users: Map<string, User>;
muted: boolean;
type: ChanType;
state: ChanState;
userAway?: boolean;
special?: SpecialChanType;
data?: any;
closed?: boolean;
num_users?: number;
static optionalProperties = ["userAway", "special", "data", "closed", "num_users"];
constructor(attr?: Partial<Chan>) {
this.id = 0;
this.messages = [];
this.name = "";
this.key = "";
this.topic = "";
this.type = ChanType.CHANNEL;
this.state = ChanState.PARTED;
this.firstUnread = 0;
this.unread = 0;
this.highlight = 0;
this.users = new Map();
this.muted = false;
if (attr) {
Object.assign(this, attr);
}
}
destroy() {
this.dereferencePreviews(this.messages);
}
pushMessage(client: Client, msg: Msg, increasesUnread = false) {
const chan = this.id;
const obj = {chan, msg} as {
chan: number;
msg: Msg;
unread?: number;
highlight?: number;
};
msg.id = client.idMsg++;
// If this channel is open in any of the clients, do not increase unread counter
const isOpen = _.find(client.attachedClients, {openChannel: chan}) !== undefined;
if (msg.self) {
// reset counters/markers when receiving self-/echo-message
this.unread = 0;
this.firstUnread = msg.id;
this.highlight = 0;
} else if (!isOpen) {
if (!this.firstUnread) {
this.firstUnread = msg.id;
}
if (increasesUnread || msg.highlight) {
obj.unread = ++this.unread;
}
if (msg.highlight) {
obj.highlight = ++this.highlight;
}
}
client.emit("msg", obj);
// Never store messages in public mode as the session
// is completely destroyed when the page gets closed
if (Config.values.public) {
return;
}
// showInActive is only processed on "msg", don't need it on page reload
if (msg.showInActive) {
delete msg.showInActive;
}
this.writeUserLog(client, msg);
if (Config.values.maxHistory >= 0 && this.messages.length > Config.values.maxHistory) {
const deleted = this.messages.splice(
0,
this.messages.length - Config.values.maxHistory
);
// If maxHistory is 0, image would be dereferenced before client had a chance to retrieve it,
// so for now, just don't implement dereferencing for this edge case.
if (Config.values.maxHistory > 0) {
this.dereferencePreviews(deleted);
}
}
}
dereferencePreviews(messages) {
if (!Config.values.prefetch || !Config.values.prefetchStorage) {
return;
}
messages.forEach((message) => {
if (message.previews) {
message.previews.forEach((preview) => {
if (preview.thumb) {
storage.dereference(preview.thumb);
preview.thumb = "";
}
});
}
});
}
getSortedUsers(irc?: Network["irc"]) {
const users = Array.from(this.users.values());
if (!irc || !irc.network || !irc.network.options || !irc.network.options.PREFIX) {
return users;
}
const userModeSortPriority = {};
irc.network.options.PREFIX.forEach((prefix, index) => {
userModeSortPriority[prefix.symbol] = index;
});
userModeSortPriority[""] = 99; // No mode is lowest
return users.sort(function (a, b) {
if (a.mode === b.mode) {
return a.nick.toLowerCase() < b.nick.toLowerCase() ? -1 : 1;
}
return userModeSortPriority[a.mode] - userModeSortPriority[b.mode];
});
}
findMessage(msgId: number) {
return this.messages.find((message) => message.id === msgId);
}
findUser(nick: string) {
return this.users.get(nick.toLowerCase());
}
getUser(nick: string) {
return this.findUser(nick) || new User({nick}, new Prefix([]));
}
setUser(user: User) {
this.users.set(user.nick.toLowerCase(), user);
}
removeUser(user: User) {
this.users.delete(user.nick.toLowerCase());
}
/**
* Get a clean clone of this channel that will be sent to the client.
* This function performs manual cloning of channel object for
* better control of performance and memory usage.
*
* @param {(int|bool)} lastActiveChannel - Last known active user channel id (needed to control how many messages are sent)
* If true, channel is assumed active.
* @param {int} lastMessage - Last message id seen by active client to avoid sending duplicates.
*/
getFilteredClone(lastActiveChannel?: number | boolean, lastMessage?: number): FilteredChannel {
return Object.keys(this).reduce((newChannel, prop) => {
if (Chan.optionalProperties.includes(prop)) {
if (this[prop] !== undefined || (Array.isArray(this[prop]) && this[prop].length)) {
newChannel[prop] = this[prop];
}
} else if (prop === "users") {
// Do not send users, client requests updated user list whenever needed
newChannel[prop] = [];
} else if (prop === "messages") {
// If client is reconnecting, only send new messages that client has not seen yet
if (lastMessage && lastMessage > -1) {
// When reconnecting, always send up to 100 messages to prevent message gaps on the client
// See https://github.com/thelounge/thelounge/issues/1883
newChannel[prop] = this[prop].filter((m) => m.id > lastMessage).slice(-100);
} else {
// If channel is active, send up to 100 last messages, for all others send just 1
// Client will automatically load more messages whenever needed based on last seen messages
const messagesToSend =
lastActiveChannel === true || this.id === lastActiveChannel ? 100 : 1;
newChannel[prop] = this[prop].slice(-messagesToSend);
}
(newChannel as FilteredChannel).totalMessages = this[prop].length;
} else {
newChannel[prop] = this[prop];
}
return newChannel;
}, {}) as FilteredChannel;
}
writeUserLog(client: Client, msg: Msg) {
this.messages.push(msg);
// Are there any logs enabled
if (client.messageStorage.length === 0) {
return;
}
const targetChannel: Chan = this;
// Is this particular message or channel loggable
if (!msg.isLoggable() || !this.isLoggable()) {
// Because notices are nasty and can be shown in active channel on the client
// if there is no open query, we want to always log notices in the sender's name
if (msg.type === MessageType.NOTICE && msg.showInActive) {
targetChannel.name = msg.from.nick || ""; // TODO: check if || works
} else {
return;
}
}
// Find the parent network where this channel is in
const target = client.find(this.id);
if (!target) {
return;
}
for (const messageStorage of client.messageStorage) {
messageStorage.index(target.network, targetChannel, msg).catch((e) => log.error(e));
}
}
loadMessages(client: Client, network: Network) {
if (!this.isLoggable()) {
return;
}
if (!network.irc) {
// Network created, but misconfigured
log.warn(
`Failed to load messages for ${client.name}, network ${network.name} is not initialized.`
);
return;
}
if (!client.messageProvider) {
if (network.irc.network.cap.isEnabled("znc.in/playback")) {
// if we do have a message provider we might be able to only fetch partial history,
// so delay the cap in this case.
requestZncPlayback(this, network, 0);
}
return;
}
client.messageProvider
.getMessages(network, this, () => client.idMsg++)
.then((messages) => {
if (messages.length === 0) {
if (network.irc!.network.cap.isEnabled("znc.in/playback")) {
requestZncPlayback(this, network, 0);
}
return;
}
this.messages.unshift(...messages);
if (!this.firstUnread) {
this.firstUnread = messages[messages.length - 1].id;
}
client.emit("more", {
chan: this.id,
messages: messages.slice(-100),
totalMessages: messages.length,
});
if (network.irc!.network.cap.isEnabled("znc.in/playback")) {
const from = Math.floor(messages[messages.length - 1].time.getTime() / 1000);
requestZncPlayback(this, network, from);
}
})
.catch((err: Error) =>
log.error(`Failed to load messages for ${client.name}: ${err.toString()}`)
);
}
isLoggable() {
return this.type === ChanType.CHANNEL || this.type === ChanType.QUERY;
}
setMuteStatus(muted: boolean) {
this.muted = !!muted;
}
}
function requestZncPlayback(channel, network, from) {
network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString());
}
export default Chan;
export type Channel = Chan;