d62dd3e62d
Message stores are more complicated that a sync "fire and forget" API allows for. For starters, non trivial stores (say sqlite) can fail during init and we want to be able to catch that. Second, we really need to be able to run migrations and such, which may block (and fail) the activation of the store. On the plus side, this pushes error handling to the caller rather than the stores, which is a good thing as that allows us to eventually push this to the client in the UI, rather than just logging it in the server on stdout
337 lines
8.9 KiB
TypeScript
337 lines
8.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;
|
|
};
|
|
|
|
class Chan {
|
|
// TODO: don't force existence, figure out how to make TS infer it.
|
|
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>) {
|
|
_.defaults(this, attr, {
|
|
id: 0,
|
|
messages: [],
|
|
name: "",
|
|
key: "",
|
|
topic: "",
|
|
type: ChanType.CHANNEL,
|
|
state: ChanState.PARTED,
|
|
firstUnread: 0,
|
|
unread: 0,
|
|
highlight: 0,
|
|
users: new Map(),
|
|
muted: false,
|
|
});
|
|
}
|
|
|
|
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)
|
|
.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;
|