706 lines
32 KiB
TypeScript
706 lines
32 KiB
TypeScript
import * as electronRemote from '@electron/remote';
|
|
const electronConsole = electronRemote.getGlobal('console') as Console;
|
|
import Logger from '../../logger/logger';
|
|
const LOG = Logger.create(__filename, electronConsole);
|
|
|
|
import ConcurrentQueue from '../../concurrent-queue/concurrent-queue';
|
|
|
|
import ElementsUtil from './elements/require/elements-util';
|
|
|
|
import Globals from './globals';
|
|
import Util from './util';
|
|
import CombinedGuild from './guild-combined';
|
|
import { Message, Member, Channel, ConnectionInfo, ShouldNeverHappenError } from './data-types';
|
|
import Q from './q-module';
|
|
import createGuildListGuild from './elements/guild-list-guild';
|
|
import createChannel from './elements/channel';
|
|
import createMember from './elements/member';
|
|
import GuildsManager from './guilds-manager';
|
|
import createMessage from './elements/message';
|
|
|
|
interface SetMessageProps {
|
|
atTop: boolean;
|
|
atBottom: boolean;
|
|
}
|
|
|
|
export default class UI {
|
|
public activeGuild: CombinedGuild | null = null;
|
|
public activeChannel: Channel | null = null;
|
|
public activeConnection: ConnectionInfo | null = null;
|
|
|
|
public messagesAtTop = false;
|
|
public messagesAtBottom = false;
|
|
|
|
public messagePairsGuild: CombinedGuild | null = null;
|
|
public messagePairsChannel: Channel | { id: string } | null = null;
|
|
public messagePairs = new Map<string | null, { message: Message, element: HTMLElement }>(); // messageId -> { message: Message, element: HTMLElement }
|
|
|
|
private document: Document;
|
|
private q: Q;
|
|
|
|
public constructor(document: Document, q: Q) {
|
|
this.document = document;
|
|
this.q = q;
|
|
}
|
|
|
|
public isMessagePairsGuild(guild: CombinedGuild): boolean {
|
|
return this.messagePairsGuild !== null && guild.id === this.messagePairsGuild.id;
|
|
}
|
|
|
|
public isMessagePairsChannel(channel: Channel): boolean {
|
|
return this.messagePairsChannel !== null && channel.id === this.messagePairsChannel.id;
|
|
}
|
|
|
|
// Use non-concurrent queues to prevent concurrent updates to parts of the view
|
|
// This is effectively a javascript version of a 'lock'
|
|
// These 'locks' should be called from working code rather than the updating functions themselves to work properly
|
|
private _guildsLock = new ConcurrentQueue<void>(1);
|
|
private _guildNameLock = new ConcurrentQueue<void>(1);
|
|
private _connectionLock = new ConcurrentQueue<void>(1);
|
|
private _channelsLock = new ConcurrentQueue<void>(1);
|
|
private _membersLock = new ConcurrentQueue<void>(1);
|
|
private _messagesLock = new ConcurrentQueue<void>(1);
|
|
|
|
private async _lockWithGuild(guild: CombinedGuild, task: (() => Promise<void> | void), lock: ConcurrentQueue<void>): Promise<void> {
|
|
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
|
|
await lock.push(async () => {
|
|
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
|
|
await task();
|
|
});
|
|
}
|
|
|
|
private async _lockWithGuildChannel(guild: CombinedGuild, channel: Channel | { id: string }, task: (() => Promise<void> | void), lock: ConcurrentQueue<void>): Promise<void> {
|
|
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
|
|
if (this.activeChannel === null || this.activeChannel.id !== channel.id) return;
|
|
await lock.push(async () => {
|
|
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
|
|
if (this.activeChannel === null || this.activeChannel.id !== channel.id) return;
|
|
await task();
|
|
});
|
|
}
|
|
|
|
public async lockGuildName(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithGuild(guild, task, this._guildNameLock);
|
|
}
|
|
|
|
public async lockConnection(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithGuild(guild, task, this._connectionLock);
|
|
}
|
|
|
|
public async lockChannels(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithGuild(guild, task, this._channelsLock);
|
|
}
|
|
|
|
public async lockMembers(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithGuild(guild, task, this._membersLock);
|
|
}
|
|
|
|
public async lockMessages(guild: CombinedGuild, channel: Channel | { id: string }, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithGuildChannel(guild, channel, task, this._messagesLock);
|
|
}
|
|
|
|
public setActiveGuild(guild: CombinedGuild): void {
|
|
if (this.activeGuild !== null) {
|
|
let prev = this.q.$_('#guild-list .guild[meta-id="' + this.activeGuild.id + '"]');
|
|
if (prev) {
|
|
prev.classList.remove('active');
|
|
}
|
|
}
|
|
let next = this.q.$('#guild-list .guild[meta-id="' + guild.id + '"]');
|
|
next.classList.add('active');
|
|
this.q.$('#guild').setAttribute('meta-id', guild.id + '');
|
|
this.activeGuild = guild;
|
|
}
|
|
|
|
public async setActiveChannel(guild: CombinedGuild, channel: Channel): Promise<void> {
|
|
await this.lockChannels(guild, () => {
|
|
// Channel List Highlight
|
|
if (this.activeChannel !== null) {
|
|
let prev = this.q.$_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"]');
|
|
if (prev) {
|
|
prev.classList.remove('active');
|
|
}
|
|
}
|
|
let next = this.q.$('#channel-list .channel[meta-id="' + channel.id + '"]');
|
|
next.classList.add('active');
|
|
|
|
// Channel Name + Flavor Text Header + Text Input Placeholder
|
|
this.q.$('#channel-name').innerText = channel.name;
|
|
this.q.$('#channel-flavor-text').innerText = channel.flavorText || '';
|
|
this.q.$('#channel-flavor-divider').style.visibility = channel.flavorText ? 'visible' : 'hidden';
|
|
this.q.$('#text-input').setAttribute('data-placeholder', 'Message #' + channel.name);
|
|
|
|
this.activeChannel = channel;
|
|
});
|
|
}
|
|
|
|
public async setActiveConnection(guild: CombinedGuild, connection: ConnectionInfo): Promise<void> {
|
|
await this.lockConnection(guild, () => {
|
|
this.activeConnection = connection;
|
|
|
|
this.q.$('#connection').className = 'member ' + connection.status;
|
|
this.q.$('#member-name').innerText = connection.displayName;
|
|
this.q.$('#member-status-text').innerText = connection.status;
|
|
|
|
this.q.$('#guild').className = '';
|
|
for (let privilege of connection.privileges) {
|
|
this.q.$('#guild').classList.add('privilege-' + privilege);
|
|
}
|
|
|
|
if (connection.avatarResourceId) {
|
|
(async () => {
|
|
// Update avatar
|
|
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
|
|
let src = await ElementsUtil.getImageBufferFromResourceFailSoftly(guild, connection.avatarResourceId);
|
|
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
|
|
(this.q.$('#member-avatar') as HTMLImageElement).src = src;
|
|
})();
|
|
} else {
|
|
(this.q.$('#member-avatar') as HTMLImageElement).src = './img/loading.svg';
|
|
}
|
|
});
|
|
}
|
|
|
|
public async setGuilds(guildsManager: GuildsManager, guilds: CombinedGuild[]): Promise<void> {
|
|
await this._guildsLock.push(() => {
|
|
Q.clearChildren(this.q.$('#guild-list'));
|
|
for (let guild of guilds) {
|
|
let element = createGuildListGuild(this.document, this.q, this, guildsManager, guild);
|
|
this.q.$('#guild-list').appendChild(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async addGuild(guildsManager: GuildsManager, guild: CombinedGuild): Promise<HTMLElement> {
|
|
let element: HTMLElement | null = null;
|
|
await this._guildsLock.push(() => {
|
|
element = createGuildListGuild(this.document, this.q, this, guildsManager, guild) as HTMLElement;
|
|
this.q.$('#guild-list').appendChild(element);
|
|
});
|
|
if (element == null) throw new ShouldNeverHappenError('element was not set');
|
|
return element;
|
|
}
|
|
|
|
public async removeGuild(guild: CombinedGuild): Promise<void> {
|
|
await this._guildsLock.push(() => {
|
|
let element = this.q.$_('#guild-list .guild[meta-id="' + guild.id + '"]');
|
|
element?.parentElement?.removeChild(element);
|
|
});
|
|
}
|
|
|
|
public async updateGuildIcon(guild: CombinedGuild, iconBuff: Buffer): Promise<void> {
|
|
await this._guildsLock.push(async () => {
|
|
let iconElement = this.q.$('#guild-list .guild[meta-id="' + guild.id + '"] img') as HTMLImageElement;
|
|
iconElement.src = await ElementsUtil.getImageBufferSrc(iconBuff);
|
|
});
|
|
}
|
|
|
|
public async updateGuildName(guild: CombinedGuild, name: string): Promise<void>{
|
|
await this.lockGuildName(guild, () => {
|
|
this.q.$('#guild-name').innerText = name;
|
|
let baseElement = this.q.$('#guild-list .guild[meta-id="' + guild.id + '"]');
|
|
baseElement.setAttribute('meta-name', name);
|
|
});
|
|
}
|
|
|
|
private _updatePosition<T>(element: HTMLElement, guildCacheMap: Map<string | null, T>, getDirection: ((prevData: T, data: T) => number)) {
|
|
let data = guildCacheMap.get(element.getAttribute('meta-id'));
|
|
if (!data) throw new ShouldNeverHappenError('unable to get data from cache map');
|
|
// TODO: do-while may be a bit cleaner?
|
|
let prev = Q.previousElement(element);
|
|
while (prev != null) {
|
|
let prevData = guildCacheMap.get(prev.getAttribute('meta-id'));
|
|
if (!prevData) throw new ShouldNeverHappenError('unable to get prevData from cache map');
|
|
if (getDirection(prevData, data) > 0) { // this element comes before previous element
|
|
prev.parentElement?.insertBefore(element, prev);
|
|
} else {
|
|
break;
|
|
}
|
|
prev = Q.previousElement(element);
|
|
}
|
|
let next = Q.nextElement(element);
|
|
while (next != null) {
|
|
let nextData = guildCacheMap.get(next.getAttribute('meta-id'));
|
|
if (!nextData) throw new ShouldNeverHappenError('unable to get nextData from cache map');
|
|
if (getDirection(data, nextData) > 0) { // this element comes after next element
|
|
next.parentElement?.insertBefore(next, element);
|
|
} else {
|
|
break;
|
|
}
|
|
next = Q.nextElement(element);
|
|
}
|
|
}
|
|
|
|
public async updateChannelPosition(guild: CombinedGuild, channelElement: HTMLElement): Promise<void> {
|
|
this._updatePosition(channelElement, await guild.grabRAMChannelsMap(), (a, b) => {
|
|
return a.index - b.index;
|
|
});
|
|
}
|
|
|
|
public async addChannels(guild: CombinedGuild, channels: Channel[], options?: { clear: boolean }): Promise<void> {
|
|
await this.lockChannels(guild, async () => {
|
|
if (options?.clear) {
|
|
Q.clearChildren(this.q.$('#channel-list'));
|
|
}
|
|
for (let channel of channels) {
|
|
let element = createChannel(this.document, this.q, this, guild, channel);
|
|
this.q.$('#channel-list').appendChild(element);
|
|
await this.updateChannelPosition(guild, element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async deleteChannels(guild: CombinedGuild, channels: Channel[]): Promise<void> {
|
|
await this.lockChannels(guild, () => {
|
|
for (let channel of channels) {
|
|
let element = this.q.$_('#channel-list .channel[meta-id="' + channel.id + '"]');
|
|
element?.parentElement?.removeChild(element);
|
|
if (this.activeChannel !== null && this.activeChannel.id == channel.id) {
|
|
this.activeChannel = null;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public async updateChannels(guild: CombinedGuild, updatedChannels: Channel[]): Promise<void> {
|
|
await this.lockChannels(guild, async () => {
|
|
for (const channel of updatedChannels) {
|
|
let oldElement = this.q.$('#channel-list .channel[meta-id="' + channel.id + '"]');
|
|
let newElement = createChannel(this.document, this.q, this, guild, channel);
|
|
oldElement.parentElement?.replaceChild(newElement, oldElement);
|
|
await this.updateChannelPosition(guild, newElement);
|
|
|
|
if (this.activeChannel !== null && this.activeChannel.id === channel.id) {
|
|
newElement.classList.add('active');
|
|
|
|
// See also setActiveChannel
|
|
this.q.$('#channel-name').innerText = channel.name;
|
|
this.q.$('#channel-flavor-text').innerText = channel.flavorText ?? '';
|
|
this.q.$('#channel-flavor-divider').style.visibility = channel.flavorText ? 'visible' : 'hidden';
|
|
this.q.$('#text-input').setAttribute('placeholder', 'Message #' + channel.name);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public async setChannels(guild: CombinedGuild, channels: Channel[]): Promise<void> {
|
|
// check if an element with the same channel and guild exists before adding the new channels
|
|
// this is nescessary to make sure that if two guilds have channels with the same id, the channel list is still
|
|
// properly refreshed and the active channel is not improperly set.
|
|
let oldMatchingElement: HTMLElement | null = null;
|
|
if (this.activeGuild !== null && this.activeChannel !== null) {
|
|
oldMatchingElement = this.q.$_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"][meta-guild-id="' + this.activeGuild.id + '"]');
|
|
}
|
|
|
|
await this.addChannels(guild, channels, { clear: true });
|
|
|
|
if (this.activeGuild !== null && this.activeGuild.id === guild.id && this.activeChannel !== null) {
|
|
let newActiveElement = this.q.$_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"][meta-guild-id="' + this.activeGuild.id + '"]');
|
|
if (newActiveElement && oldMatchingElement) {
|
|
let activeChannelId = this.activeChannel.id;
|
|
let channel = channels.find(channel => channel.id === activeChannelId);
|
|
if (channel === undefined) throw new ShouldNeverHappenError('current channel does not exist in channels list')
|
|
this.setActiveChannel(guild, channel);
|
|
} else {
|
|
this.activeChannel = null; // the active channel was removed
|
|
}
|
|
}
|
|
}
|
|
|
|
public async setChannelsErrorIndicator(guild: CombinedGuild, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockChannels(guild, () => {
|
|
Q.clearChildren(this.q.$('#channel-list'));
|
|
this.q.$('#channel-list').appendChild(errorIndicatorElement);
|
|
});
|
|
}
|
|
|
|
public async updateMemberPosition(guild: CombinedGuild, memberElement: HTMLElement): Promise<void> {
|
|
// TODO: Change 100 to a constant?
|
|
let statusOrder = {
|
|
'online': 0,
|
|
'away': 1,
|
|
'busy': 2,
|
|
'offline': 3,
|
|
'invisible': 3, // this would only be shown in the case of the current member.
|
|
'unknown': 100,
|
|
};
|
|
this._updatePosition(memberElement, await guild.grabRAMMembersMap(), (a, b) => {
|
|
let onlineCmp = (a.status == 'offline' ? 1 : 0) - (b.status == 'offline' ? 1 : 0);
|
|
if (onlineCmp != 0) return onlineCmp;
|
|
let rolePriorityCmp = (a.rolePriority == null ? 100 : a.rolePriority) - (b.rolePriority == null ? 100 : b.rolePriority);
|
|
if (rolePriorityCmp != 0) return rolePriorityCmp;
|
|
let statusCmp = statusOrder[a.status] - statusOrder[b.status];
|
|
if (statusCmp != 0) return statusCmp;
|
|
let nameCmp = a.displayName.localeCompare(b.displayName);
|
|
return nameCmp;
|
|
});
|
|
}
|
|
|
|
public async addMembers(guild: CombinedGuild, members: Member[], options?: { clear: boolean }): Promise<void> {
|
|
await this.lockMembers(guild, async () => {
|
|
if (options?.clear) {
|
|
Q.clearChildren(this.q.$('#guild-members'));
|
|
}
|
|
for (let member of members) {
|
|
let element = createMember(this.q, guild, member);
|
|
this.q.$('#guild-members').appendChild(element);
|
|
await this.updateMemberPosition(guild, element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async deleteMembers(guild: CombinedGuild, members: Member[]): Promise<void> {
|
|
await this.lockMembers(guild, () => {
|
|
for (let member of members) {
|
|
let element = this.q.$_('#guild-members .member[meta-id="' + member.id + '"]');
|
|
element?.parentElement?.removeChild(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async updateMembers(guild: CombinedGuild, updatedMembers: Member[]): Promise<void> {
|
|
await this.lockMembers(guild, async () => {
|
|
for (const member of updatedMembers) {
|
|
let oldElement = this.q.$_('#guild-members .member[meta-id="' + member.id + '"]');
|
|
if (oldElement) {
|
|
let newElement = createMember(this.q, guild, member);
|
|
oldElement.parentElement?.replaceChild(newElement, oldElement);
|
|
await this.updateMemberPosition(guild, newElement);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update the messages too
|
|
if (this.activeChannel === null) return;
|
|
await this.lockMessages(guild, this.activeChannel, () => {
|
|
for (const member of updatedMembers) {
|
|
let newStyle = member.roleColor ? 'color: ' + member.roleColor : null;
|
|
let newName = member.displayName;
|
|
// the extra query selectors may be overkill
|
|
for (let messageElement of this.q.$$(`.message[meta-member-id="${member.id}"]`)) {
|
|
let nameElement = this.q.$$$_(messageElement, '.member-name');
|
|
if (nameElement) { // continued messages will still show up but need to be skipped
|
|
if (newStyle) nameElement.setAttribute('style', newStyle);
|
|
nameElement.innerText = newName;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public async setMembers(guild: CombinedGuild, members: Member[]): Promise<void> {
|
|
await this.addMembers(guild, members, { clear: true });
|
|
}
|
|
|
|
public async setMembersErrorIndicator(guild: CombinedGuild, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockMembers(guild, () => {
|
|
Q.clearChildren(this.q.$('#guild-members'));
|
|
this.q.$('#guild-members').appendChild(errorIndicatorElement);
|
|
});
|
|
}
|
|
|
|
public getTopMessagePair(): { message: Message, element: HTMLElement } | null {
|
|
let element = this.q.$$('#channel-feed .message')[0];
|
|
return element && this.messagePairs.get(element.getAttribute('meta-id')) || null;
|
|
}
|
|
|
|
public getBottomMessagePair(): { message: Message, element: HTMLElement } | null {
|
|
let messageElements = this.q.$$('#channel-feed .message');
|
|
let element = messageElements[messageElements.length - 1];
|
|
return element && this.messagePairs.get(element.getAttribute('meta-id')) || null;
|
|
}
|
|
|
|
public async addMessages(guild: CombinedGuild, messages: Message[]) {
|
|
let channelIds = new Set(messages.map(message => message.channel.id));
|
|
for (let channelId of channelIds) {
|
|
let channelMessages = messages.filter(message => message.channel.id === channelId);
|
|
channelMessages = channelMessages.sort((a, b) => a.sent.getTime() - b.sent.getTime());
|
|
|
|
// No Previous Messages is an easy case
|
|
if (this.messagePairs.size === 0) {
|
|
await this.addMessagesBefore(guild, { id: channelId }, channelMessages, null);
|
|
continue;
|
|
}
|
|
|
|
let topMessagePair = this.getTopMessagePair() as { message: Message, element: HTMLElement };
|
|
let bottomMessagePair = this.getBottomMessagePair() as { message: Message, element: HTMLElement };
|
|
|
|
let aboveMessages = messages.filter(message => message.sent < topMessagePair.message.sent);
|
|
let belowMessages = messages.filter(message => message.sent > bottomMessagePair.message.sent);
|
|
let betweenMessages = messages.filter(message => message.sent >= topMessagePair.message.sent && message.sent <= bottomMessagePair.message.sent);
|
|
|
|
if (aboveMessages.length > 0) await this.addMessagesBefore(guild, { id: channelId }, aboveMessages, topMessagePair.message);
|
|
if (belowMessages.length > 0) await this.addMessagesAfter(guild, { id: channelId }, belowMessages, bottomMessagePair.message);
|
|
if (betweenMessages.length > 0) await this.addMessagesBetween(guild, { id: channelId }, betweenMessages, topMessagePair.element, bottomMessagePair.element);
|
|
}
|
|
}
|
|
|
|
public async addMessagesBefore(guild: CombinedGuild, channel: Channel | { id: string }, messages: Message[], prevTopMessage: Message | null): Promise<void> {
|
|
this.lockMessages(guild, channel, () => {
|
|
if (prevTopMessage && this.getTopMessagePair()?.message.id !== prevTopMessage.id) return;
|
|
|
|
this.messagesAtTop = false;
|
|
|
|
// There are a maximum of MAX_CURRENT_MESSAGES messages in the channel at a time
|
|
// Remove messages at the bottom to make space for new messages
|
|
if (this.messagePairs.size + messages.length > Globals.MAX_CURRENT_MESSAGES) {
|
|
let currentMessageElements = this.q.$$('#channel-feed .message');
|
|
if (this.messagePairs.size !== currentMessageElements.length) throw new Error(`message lengths disjointed, ${this.messagePairs.size} != ${currentMessageElements.length}`); // sanity check
|
|
let toRemove = currentMessageElements.slice(-(this.messagePairs.size + messages.length - Globals.MAX_CURRENT_MESSAGES));
|
|
for (let element of toRemove) {
|
|
let id = element.getAttribute('meta-id');
|
|
this.messagePairs.delete(id);
|
|
element.parentElement?.removeChild(element);
|
|
}
|
|
this.messagesAtBottom = false;
|
|
}
|
|
|
|
// Relies on error indicators being in top-to-bottom order in the list
|
|
Util.removeErrorIndicators(this.q, this.q.$('#channel-feed'), [ 'recent' ]);
|
|
Util.removeErrorIndicators(this.q, this.q.$('#channel-feed'), [ 'before' ]);
|
|
|
|
// Keep track of the top messages before we add new messages
|
|
let prevTopPair = this.getTopMessagePair();
|
|
|
|
// Add the messages to the channel feed
|
|
// Using reverse order so that resources are loaded from bottom to top
|
|
// and the client starts at the bottom
|
|
for (let i = messages.length - 1; i >= 0; --i) {
|
|
let message = messages[i];
|
|
let priorMessage = messages[i - 1] || null;
|
|
let element = createMessage(this.document, this.q, guild, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
this.q.$('#channel-feed').prepend(element);
|
|
}
|
|
|
|
if (messages.length > 0 && prevTopPair) {
|
|
// Update the previous top message since it may have changed format
|
|
let newPrevTopElement = createMessage(this.document, this.q, guild, prevTopPair.message, messages[messages.length - 1]);
|
|
prevTopPair.element.parentElement?.replaceChild(newPrevTopElement, prevTopPair.element);
|
|
this.messagePairs.set(prevTopPair.message.id, { message: prevTopPair.message, element: newPrevTopElement });
|
|
}
|
|
});
|
|
}
|
|
|
|
public async addMessagesAfter(guild: CombinedGuild, channel: Channel | { id: string }, messages: Message[], prevBottomMessage: Message | null): Promise<void> {
|
|
await this.lockMessages(guild, channel, () => {
|
|
if (prevBottomMessage && this.getBottomMessagePair()?.message.id !== prevBottomMessage.id) return;
|
|
|
|
this.messagesAtBottom = false;
|
|
|
|
// There are a maximum of MAX_CURRENT_MESSAGES messages in the channel at a time
|
|
// Remove messages at the top to make space for new messages
|
|
if (this.messagePairs.size + messages.length > Globals.MAX_CURRENT_MESSAGES) {
|
|
let currentMessageElements = this.q.$$('#channel-feed .message');
|
|
if (this.messagePairs.size !== currentMessageElements.length) throw new Error('message lengths disjointed'); // sanity check
|
|
let toRemove = currentMessageElements.slice(0, this.messagePairs.size + messages.length - Globals.MAX_CURRENT_MESSAGES);
|
|
for (let element of toRemove) {
|
|
let id = element.getAttribute('meta-id');
|
|
this.messagePairs.delete(id);
|
|
element.parentElement?.removeChild(element);
|
|
}
|
|
this.messagesAtTop = false;
|
|
}
|
|
|
|
Util.removeErrorIndicators(this.q, this.q.$('#channel-feed'), [ 'recent' ]);
|
|
Util.removeErrorIndicators(this.q, this.q.$('#channel-feed'), [ 'after' ]);
|
|
|
|
// Get the bottom message to use as the prior message to the first new message
|
|
let prevBottomPair = this.getBottomMessagePair();
|
|
|
|
// Add new messages to the bottom of the channel feed
|
|
// Using forward-order so that resources are loaded from oldest messages to newest messages
|
|
// since we are expecting the user to scroll down (to newer messages)
|
|
for (let i = 0; i < messages.length; ++i) { // add in-order since we will be scrolling from oldest to newest
|
|
let message = messages[i];
|
|
let priorMessage = messages[i - 1] || (prevBottomPair && prevBottomPair.message);
|
|
let element = createMessage(this.document, this.q, guild, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
this.q.$('#channel-feed').appendChild(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
// TODO: use topMessage, bottomMessage / topMessageId, bottomMessageId instead?
|
|
private async addMessagesBetween(guild: CombinedGuild, channel: Channel | { id: string }, messages: Message[], topElement: HTMLElement, bottomElement: HTMLElement): Promise<void> {
|
|
await this.lockMessages(guild, channel, () => {
|
|
if (!(messages.length > 0 && topElement != null && bottomElement != null && bottomElement == Q.nextElement(topElement))) {
|
|
LOG.error('invalid messages between', { messages, top: topElement.innerText, bottom: bottomElement.innerText, afterTop: Q.nextElement(topElement)?.innerText });
|
|
throw new Error('invalid messages between');
|
|
}
|
|
|
|
if (this.messagePairs.size + messages.length > Globals.MAX_CURRENT_MESSAGES) {
|
|
let currentMessageElements = this.q.$$('#channel-feed .message');
|
|
if (this.messagePairs.size !== currentMessageElements.length) throw new Error('message lengths disjointed'); // sanity check
|
|
let totalToRemove = this.messagePairs.size + messages.length - Globals.MAX_CURRENT_MESSAGES
|
|
let toRemove: HTMLElement[] = [];
|
|
// figure out if the elements are getting added above or below the scroll box.
|
|
// NOT TESTED YET: elements added within the scroll box are assumed to make the box resize downward
|
|
let above = bottomElement.offsetTop > this.q.$('#channel-feed-wrapper').scrollTop;
|
|
if (above) {
|
|
// remove elements at the top first
|
|
for (let messageElement of currentMessageElements) {
|
|
if (toRemove.length == totalToRemove) {
|
|
break;
|
|
}
|
|
if (messageElement.getAttribute('meta-id') == topElement.getAttribute('meta-id')) {
|
|
break;
|
|
}
|
|
toRemove.push(messageElement);
|
|
}
|
|
// remove elements at the bottom if still needed
|
|
for (let messageElement of currentMessageElements.reverse()) {
|
|
if (toRemove.length == totalToRemove) {
|
|
break;
|
|
}
|
|
if (messageElement.getAttribute('meta-id') == bottomElement.getAttribute('meta-id')) {
|
|
break;
|
|
}
|
|
toRemove.push(messageElement);
|
|
}
|
|
} else {
|
|
// remove elements at the bottom first
|
|
for (let messageElement of currentMessageElements.reverse()) {
|
|
if (toRemove.length == totalToRemove) {
|
|
break;
|
|
}
|
|
if (messageElement.getAttribute('meta-id') == topElement.getAttribute('meta-id')) {
|
|
break;
|
|
}
|
|
toRemove.push(messageElement);
|
|
}
|
|
// remove elements at the top if still needed
|
|
for (let messageElement of currentMessageElements.reverse()) {
|
|
if (toRemove.length == totalToRemove) {
|
|
break;
|
|
}
|
|
if (messageElement.getAttribute('meta-id') == bottomElement.getAttribute('meta-id')) {
|
|
break;
|
|
}
|
|
toRemove.push(messageElement);
|
|
}
|
|
}
|
|
for (let element of toRemove) {
|
|
let id = element.getAttribute('meta-id');
|
|
if (!id) continue;
|
|
this.messagePairs.delete(id);
|
|
element.parentElement?.removeChild(element);
|
|
}
|
|
}
|
|
|
|
let topElementId = topElement.getAttribute('meta-id');
|
|
let topMessage = this.messagePairs.get(topElementId)?.message;
|
|
for (let i = 0; i < messages.length; ++i) {
|
|
let message = messages[i];
|
|
let priorMessage = messages[i - 1] || topMessage;
|
|
let element = createMessage(this.document, this.q, guild, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
this.q.$('#channel-feed').insertBefore(element, bottomElement);
|
|
}
|
|
|
|
if (messages.length > 0) {
|
|
// update the bottom element since the element above it changed
|
|
let bottomMessage = this.messagePairs.get(bottomElement.getAttribute('meta-id'))?.message;
|
|
if (!bottomMessage) throw new ShouldNeverHappenError('could not find bottom message');
|
|
let newBottomElement = createMessage(this.document, this.q, guild, bottomMessage, messages[messages.length - 1]);
|
|
bottomElement.parentElement?.replaceChild(newBottomElement, bottomElement);
|
|
this.messagePairs.set(bottomMessage.id, { element: newBottomElement, message: bottomMessage });
|
|
}
|
|
});
|
|
}
|
|
|
|
public async setMessages(guild: CombinedGuild, channel: Channel | { id: string }, messages: Message[], props: SetMessageProps): Promise<void> {
|
|
const { atTop, atBottom } = props;
|
|
await this.lockMessages(guild, channel, () => {
|
|
this.messagesAtTop = atTop;
|
|
this.messagesAtBottom = atBottom;
|
|
|
|
Util.removeErrorIndicators(this.q, this.q.$('#channel-feed'), [ 'recent' ]);
|
|
Util.removeErrorIndicators(this.q, this.q.$('#channel-feed'), [ 'before' ]);
|
|
Util.removeErrorIndicators(this.q, this.q.$('#channel-feed'), [ 'after' ]);
|
|
|
|
this.messagePairsGuild = guild;
|
|
this.messagePairsChannel = channel;
|
|
|
|
this.messagePairs.clear();
|
|
Q.clearChildren(this.q.$('#channel-feed'));
|
|
|
|
// Add the messages to the channel feed
|
|
// Using reverse order so that resources are loaded from bottom to top
|
|
// and the client starts at the bottom
|
|
for (let i = messages.length - 1; i >= 0; --i) {
|
|
let message = messages[i];
|
|
let priorMessage = messages[i - 1] || null;
|
|
let element = createMessage(this.document, this.q, guild, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
this.q.$('#channel-feed').prepend(element);
|
|
}
|
|
|
|
this.jumpMessagesToBottom();
|
|
});
|
|
}
|
|
|
|
public jumpMessagesToBottom(): void {
|
|
this.q.$('#channel-feed-content-wrapper').scrollTop = this.q.$('#channel-feed-content-wrapper').scrollHeight;
|
|
this.messagesAtBottom = true;
|
|
}
|
|
|
|
public async deleteMessages(guild: CombinedGuild, messages: Message[]) {
|
|
let channelIds = new Set(messages.map(message => message.channel.id));
|
|
for (let channelId of channelIds) {
|
|
let channelMessages = messages.filter(message => message.channel.id === channelId);
|
|
await this.lockMessages(guild, { id: channelId }, () => {
|
|
for (let message of channelMessages) {
|
|
if (this.messagePairs.has(message.id)) {
|
|
let messagePair = this.messagePairs.get(message.id) as { message: Message, element: HTMLElement };
|
|
messagePair.element.parentElement?.removeChild(messagePair.element);
|
|
// TODO: we should be updating messages sent below this message
|
|
// however, these events should be relatively rare so that's for the future
|
|
this.messagePairs.delete(message.id);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public async updateMessages(guild: CombinedGuild, updatedMessages: Message[]): Promise<void> {
|
|
let channelIds = new Set(updatedMessages.map(message => message.channel.id));
|
|
for (let channelId of channelIds) {
|
|
let channelMessages = updatedMessages.filter(message => message.channel.id === channelId);
|
|
await this.lockMessages(guild, { id: channelId }, () => {
|
|
for (const message of channelMessages) {
|
|
if (this.messagePairs.has(message.id)) {
|
|
let oldElement = (this.messagePairs.get(message.id) as { message: Message, element: HTMLElement }).element;
|
|
let prevElement = Q.previousElement(oldElement);
|
|
let prevMessage = prevElement && (this.messagePairs.get(prevElement.getAttribute('meta-id')) as { message: Message, element: HTMLElement }).message;
|
|
let newElement = createMessage(this.document, this.q, guild, message, prevMessage);
|
|
oldElement.parentElement?.replaceChild(newElement, oldElement);
|
|
// TODO: we should be updating messages sent below this message
|
|
// however, these events should be relatively rare so that's for the future
|
|
this.messagePairs.set(message.id, { message: message, element: newElement });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public async addMessagesErrorIndicatorBefore(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockMessages(guild, channel, () => {
|
|
this.q.$('#channel-feed').prepend(errorIndicatorElement);
|
|
});
|
|
}
|
|
|
|
public async addMessagesErrorIndicatorAfter(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockMessages(guild, channel, () => {
|
|
this.q.$('#channel-feed').appendChild(errorIndicatorElement);
|
|
});
|
|
}
|
|
|
|
public async setMessagesErrorIndicator(guild: CombinedGuild, channel: Channel | { id: string }, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockMessages(guild, channel, () => {
|
|
Q.clearChildren(this.q.$('#channel-feed'));
|
|
this.q.$('#channel-feed').appendChild(errorIndicatorElement);
|
|
});
|
|
}
|
|
}
|