cordis/client/webapp/ui.ts

706 lines
32 KiB
TypeScript
Raw Normal View History

2021-10-30 17:26:41 +00:00
import * as electronRemote from '@electron/remote';
const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../logger/logger';
2021-11-02 04:29:24 +00:00
const LOG = Logger.create(__filename, electronConsole);
2021-10-30 17:26:41 +00:00
import ConcurrentQueue from '../../concurrent-queue/concurrent-queue';
import ElementsUtil from './elements/require/elements-util';
import Globals from './globals';
import Util from './util';
2021-11-21 18:29:42 +00:00
import CombinedGuild from './guild-combined';
import { Message, Member, Channel, ConnectionInfo, ShouldNeverHappenError } from './data-types';
import Q from './q-module';
2021-11-21 18:29:42 +00:00
import createGuildListGuild from './elements/guild-list-guild';
import createChannel from './elements/channel';
import createMember from './elements/member';
2021-11-22 02:47:29 +00:00
import GuildsManager from './guilds-manager';
import createMessage from './elements/message';
2021-10-30 17:26:41 +00:00
interface SetMessageProps {
atTop: boolean;
atBottom: boolean;
2021-10-30 17:26:41 +00:00
}
export default class UI {
2021-11-21 18:29:42 +00:00
public activeGuild: CombinedGuild | null = null;
public activeChannel: Channel | null = null;
public activeConnection: ConnectionInfo | null = null;
public messagesAtTop = false;
public messagesAtBottom = false;
2021-11-21 18:29:42 +00:00
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;
}
2021-11-21 18:29:42 +00:00
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
2021-11-21 18:29:42 +00:00
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);
2021-11-21 18:29:42 +00:00
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 () => {
2021-11-21 18:29:42 +00:00
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
await task();
});
}
2021-11-21 18:29:42 +00:00
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 () => {
2021-11-21 18:29:42 +00:00
if (this.activeGuild === null || this.activeGuild.id !== guild.id) return;
if (this.activeChannel === null || this.activeChannel.id !== channel.id) return;
await task();
});
}
2021-11-21 18:29:42 +00:00
public async lockGuildName(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
await this._lockWithGuild(guild, task, this._guildNameLock);
}
2021-11-21 18:29:42 +00:00
public async lockConnection(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
await this._lockWithGuild(guild, task, this._connectionLock);
}
2021-11-21 18:29:42 +00:00
public async lockChannels(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
await this._lockWithGuild(guild, task, this._channelsLock);
}
2021-11-21 18:29:42 +00:00
public async lockMembers(guild: CombinedGuild, task: (() => Promise<void> | void)): Promise<void> {
await this._lockWithGuild(guild, task, this._membersLock);
}
2021-11-21 18:29:42 +00:00
public async lockMessages(guild: CombinedGuild, channel: Channel | { id: string }, task: (() => Promise<void> | void)): Promise<void> {
await this._lockWithGuildChannel(guild, channel, task, this._messagesLock);
}
2021-11-21 18:29:42 +00:00
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');
}
}
2021-11-21 18:29:42 +00:00
let next = this.q.$('#guild-list .guild[meta-id="' + guild.id + '"]');
next.classList.add('active');
2021-11-21 18:29:42 +00:00
this.q.$('#guild').setAttribute('meta-id', guild.id + '');
this.activeGuild = guild;
}
2021-11-21 18:29:42 +00:00
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';
2021-11-08 02:38:52 +00:00
this.q.$('#text-input').setAttribute('data-placeholder', 'Message #' + channel.name);
this.activeChannel = channel;
});
}
2021-11-21 18:29:42 +00:00
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;
2021-11-21 18:29:42 +00:00
this.q.$('#guild').className = '';
for (let privilege of connection.privileges) {
2021-11-21 18:29:42 +00:00
this.q.$('#guild').classList.add('privilege-' + privilege);
}
if (connection.avatarResourceId) {
(async () => {
// Update avatar
2021-11-21 18:29:42 +00:00
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';
}
});
}
2021-11-22 02:47:29 +00:00
public async setGuilds(guildsManager: GuildsManager, guilds: CombinedGuild[]): Promise<void> {
2021-11-21 18:29:42 +00:00
await this._guildsLock.push(() => {
Q.clearChildren(this.q.$('#guild-list'));
for (let guild of guilds) {
2021-11-22 02:47:29 +00:00
let element = createGuildListGuild(this.document, this.q, this, guildsManager, guild);
2021-11-21 18:29:42 +00:00
this.q.$('#guild-list').appendChild(element);
}
});
}
2021-11-22 02:47:29 +00:00
public async addGuild(guildsManager: GuildsManager, guild: CombinedGuild): Promise<HTMLElement> {
let element: HTMLElement | null = null;
2021-11-21 18:29:42 +00:00
await this._guildsLock.push(() => {
2021-11-22 02:47:29 +00:00
element = createGuildListGuild(this.document, this.q, this, guildsManager, guild) as HTMLElement;
2021-11-21 18:29:42 +00:00
this.q.$('#guild-list').appendChild(element);
});
if (element == null) throw new ShouldNeverHappenError('element was not set');
return element;
}
2021-11-21 18:29:42 +00:00
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);
});
}
2021-11-21 18:29:42 +00:00
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);
});
}
2021-11-21 18:29:42 +00:00
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);
});
}
2021-11-21 18:29:42 +00:00
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) {
2021-11-21 18:29:42 +00:00
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) {
2021-11-21 18:29:42 +00:00
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);
}
}
2021-11-21 18:29:42 +00:00
public async updateChannelPosition(guild: CombinedGuild, channelElement: HTMLElement): Promise<void> {
this._updatePosition(channelElement, await guild.grabRAMChannelsMap(), (a, b) => {
return a.index - b.index;
});
}
2021-11-21 18:29:42 +00:00
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) {
2021-11-21 18:29:42 +00:00
let element = createChannel(this.document, this.q, this, guild, channel);
this.q.$('#channel-list').appendChild(element);
2021-11-21 18:29:42 +00:00
await this.updateChannelPosition(guild, element);
}
});
}
2021-11-21 18:29:42 +00:00
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;
}
}
});
}
2021-11-22 02:47:29 +00:00
public async updateChannels(guild: CombinedGuild, updatedChannels: Channel[]): Promise<void> {
2021-11-21 18:29:42 +00:00
await this.lockChannels(guild, async () => {
2021-11-22 02:47:29 +00:00
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);
2021-11-21 18:29:42 +00:00
await this.updateChannelPosition(guild, newElement);
2021-11-22 02:47:29 +00:00
if (this.activeChannel !== null && this.activeChannel.id === channel.id) {
newElement.classList.add('active');
// See also setActiveChannel
2021-11-22 02:47:29 +00:00
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);
}
}
});
}
2021-11-21 18:29:42 +00:00
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;
2021-11-21 18:29:42 +00:00
if (this.activeGuild !== null && this.activeChannel !== null) {
oldMatchingElement = this.q.$_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"][meta-guild-id="' + this.activeGuild.id + '"]');
}
2021-11-21 18:29:42 +00:00
await this.addChannels(guild, channels, { clear: true });
2021-11-21 18:29:42 +00:00
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')
2021-11-21 18:29:42 +00:00
this.setActiveChannel(guild, channel);
} else {
this.activeChannel = null; // the active channel was removed
}
}
}
2021-11-21 18:29:42 +00:00
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);
});
}
2021-11-21 18:29:42 +00:00
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,
};
2021-11-21 18:29:42 +00:00
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;
});
}
2021-11-21 18:29:42 +00:00
public async addMembers(guild: CombinedGuild, members: Member[], options?: { clear: boolean }): Promise<void> {
await this.lockMembers(guild, async () => {
if (options?.clear) {
2021-11-21 18:29:42 +00:00
Q.clearChildren(this.q.$('#guild-members'));
}
for (let member of members) {
2021-11-21 18:29:42 +00:00
let element = createMember(this.q, guild, member);
this.q.$('#guild-members').appendChild(element);
await this.updateMemberPosition(guild, element);
}
});
}
2021-11-21 18:29:42 +00:00
public async deleteMembers(guild: CombinedGuild, members: Member[]): Promise<void> {
await this.lockMembers(guild, () => {
for (let member of members) {
2021-11-21 18:29:42 +00:00
let element = this.q.$_('#guild-members .member[meta-id="' + member.id + '"]');
element?.parentElement?.removeChild(element);
}
});
}
2021-11-22 02:47:29 +00:00
public async updateMembers(guild: CombinedGuild, updatedMembers: Member[]): Promise<void> {
2021-11-21 18:29:42 +00:00
await this.lockMembers(guild, async () => {
2021-11-22 02:47:29 +00:00
for (const member of updatedMembers) {
let oldElement = this.q.$_('#guild-members .member[meta-id="' + member.id + '"]');
if (oldElement) {
2021-11-22 02:47:29 +00:00
let newElement = createMember(this.q, guild, member);
oldElement.parentElement?.replaceChild(newElement, oldElement);
2021-11-21 18:29:42 +00:00
await this.updateMemberPosition(guild, newElement);
}
}
});
2021-11-22 02:47:29 +00:00
// Update the messages too
if (this.activeChannel === null) return;
2021-11-21 18:29:42 +00:00
await this.lockMessages(guild, this.activeChannel, () => {
2021-11-22 02:47:29 +00:00
for (const member of updatedMembers) {
let newStyle = member.roleColor ? 'color: ' + member.roleColor : null;
let newName = member.displayName;
// the extra query selectors may be overkill
2021-11-22 02:47:29 +00:00
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;
}
}
}
});
}
2021-11-21 18:29:42 +00:00
public async setMembers(guild: CombinedGuild, members: Member[]): Promise<void> {
await this.addMembers(guild, members, { clear: true });
}
2021-11-21 18:29:42 +00:00
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;
}
2021-11-22 02:47:29 +00:00
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> {
2021-11-21 18:29:42 +00:00
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;
2021-11-21 18:29:42 +00:00
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
2021-11-21 18:29:42 +00:00
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 });
}
});
}
2021-11-21 18:29:42 +00:00
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);
2021-11-21 18:29:42 +00:00
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?
2021-11-22 02:47:29 +00:00
private async addMessagesBetween(guild: CombinedGuild, channel: Channel | { id: string }, messages: Message[], topElement: HTMLElement, bottomElement: HTMLElement): Promise<void> {
2021-11-21 18:29:42 +00:00
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;
2021-11-21 18:29:42 +00:00
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');
2021-11-21 18:29:42 +00:00
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 });
}
});
}
2021-11-21 18:29:42 +00:00
public async setMessages(guild: CombinedGuild, channel: Channel | { id: string }, messages: Message[], props: SetMessageProps): Promise<void> {
const { atTop, atBottom } = props;
2021-11-21 18:29:42 +00:00
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' ]);
2021-11-21 18:29:42 +00:00
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;
2021-11-21 18:29:42 +00:00
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;
}
2021-11-22 02:47:29 +00:00
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);
}
}
2021-11-22 02:47:29 +00:00
});
}
}
2021-11-22 02:47:29 +00:00
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 });
}
}
2021-11-22 02:47:29 +00:00
});
}
}
2021-11-21 18:29:42 +00:00
public async addMessagesErrorIndicatorBefore(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise<void> {
await this.lockMessages(guild, channel, () => {
this.q.$('#channel-feed').prepend(errorIndicatorElement);
});
}
2021-11-21 18:29:42 +00:00
public async addMessagesErrorIndicatorAfter(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise<void> {
await this.lockMessages(guild, channel, () => {
this.q.$('#channel-feed').appendChild(errorIndicatorElement);
});
}
2021-11-21 18:29:42 +00:00
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);
});
}
2021-10-30 17:26:41 +00:00
}