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 Controller from './controller'; 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(); // 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(1); private _guildNameLock = new ConcurrentQueue(1); private _connectionLock = new ConcurrentQueue(1); private _channelsLock = new ConcurrentQueue(1); private _membersLock = new ConcurrentQueue(1); private _messagesLock = new ConcurrentQueue(1); private async _lockWithGuild(guild: CombinedGuild, task: (() => Promise | void), lock: ConcurrentQueue): Promise { 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), lock: ConcurrentQueue): Promise { 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)): Promise { await this._lockWithGuild(guild, task, this._guildNameLock); } public async lockConnection(guild: CombinedGuild, task: (() => Promise | void)): Promise { await this._lockWithGuild(guild, task, this._connectionLock); } public async lockChannels(guild: CombinedGuild, task: (() => Promise | void)): Promise { await this._lockWithGuild(guild, task, this._channelsLock); } public async lockMembers(guild: CombinedGuild, task: (() => Promise | void)): Promise { await this._lockWithGuild(guild, task, this._membersLock); } public async lockMessages(guild: CombinedGuild, channel: Channel | { id: string }, task: (() => Promise | void)): Promise { 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 { 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 { 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(controller: Controller, guilds: CombinedGuild[]): Promise { await this._guildsLock.push(() => { Q.clearChildren(this.q.$('#guild-list')); for (let guild of guilds) { let element = createGuildListGuild(this.document, this.q, this, controller, guild); this.q.$('#guild-list').appendChild(element); } }); } public async addGuild(controller: Controller, guild: CombinedGuild): Promise { let element: HTMLElement | null = null; await this._guildsLock.push(() => { element = createGuildListGuild(this.document, this.q, this, controller, 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 { 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 { 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{ 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(element: HTMLElement, guildCacheMap: Map, 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 { this._updatePosition(channelElement, await guild.grabRAMChannelsMap(), (a, b) => { return a.index - b.index; }); } public async addChannels(guild: CombinedGuild, channels: Channel[], options?: { clear: boolean }): Promise { 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 { 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, data: { oldChannel: Channel, newChannel: Channel }[]): Promise { await this.lockChannels(guild, async () => { for (const { oldChannel, newChannel } of data) { let oldElement = this.q.$('#channel-list .channel[meta-id="' + newChannel.id + '"]'); let newElement = createChannel(this.document, this.q, this, guild, newChannel); oldElement.parentElement?.replaceChild(newElement, oldElement); await this.updateChannelPosition(guild, newElement); if (this.activeChannel !== null && this.activeChannel.id === newChannel.id) { newElement.classList.add('active'); // See also setActiveChannel this.q.$('#channel-name').innerText = newChannel.name; this.q.$('#channel-flavor-text').innerText = newChannel.flavorText ?? ''; this.q.$('#channel-flavor-divider').style.visibility = newChannel.flavorText ? 'visible' : 'hidden'; this.q.$('#text-input').setAttribute('placeholder', 'Message #' + newChannel.name); } } }); } public async setChannels(guild: CombinedGuild, channels: Channel[]): Promise { // 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 { await this.lockChannels(guild, () => { Q.clearChildren(this.q.$('#channel-list')); this.q.$('#channel-list').appendChild(errorIndicatorElement); }); } public async updateMemberPosition(guild: CombinedGuild, memberElement: HTMLElement): Promise { // 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 { 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 { 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, data: { oldMember: Member, newMember: Member }[]): Promise { await this.lockMembers(guild, async () => { for (const { oldMember, newMember } of data) { let oldElement = this.q.$_('#guild-members .member[meta-id="' + newMember.id + '"]'); if (oldElement) { let newElement = createMember(this.q, guild, newMember); oldElement.parentElement?.replaceChild(newElement, oldElement); await this.updateMemberPosition(guild, newElement); } } }); if (this.activeChannel === null) return; await this.lockMessages(guild, this.activeChannel, () => { for (const { oldMember, newMember } of data) { let newStyle = newMember.roleColor ? 'color: ' + newMember.roleColor : null; let newName = newMember.displayName; // the extra query selectors may be overkill for (let messageElement of this.q.$$(`.message[meta-member-id="${newMember.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 { await this.addMembers(guild, members, { clear: true }); } public async setMembersErrorIndicator(guild: CombinedGuild, errorIndicatorElement: HTMLElement): Promise { 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 addMessagesBefore(guild: CombinedGuild, channel: Channel, messages: Message[], prevTopMessage: Message | null): Promise { 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 { 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? public async addMessagesBetween(guild: CombinedGuild, channel: Channel, messages: Message[], topElement: HTMLElement, bottomElement: HTMLElement): Promise { 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 { 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, channel: Channel, messages: Message[]) { await this.lockMessages(guild, channel, () => { for (let message of messages) { 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, channel: Channel, data: { oldMessage: Message, newMessage: Message }[]): Promise { await this.lockMessages(guild, channel, () => { for (const { oldMessage, newMessage } of data) { if (this.messagePairs.has(oldMessage.id)) { let oldElement = (this.messagePairs.get(oldMessage.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, newMessage, 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(oldMessage.id, { message: newMessage, element: newElement }); } } }); } public async addMessagesErrorIndicatorBefore(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise { await this.lockMessages(guild, channel, () => { this.q.$('#channel-feed').prepend(errorIndicatorElement); }); } public async addMessagesErrorIndicatorAfter(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise { await this.lockMessages(guild, channel, () => { this.q.$('#channel-feed').appendChild(errorIndicatorElement); }); } public async setMessagesErrorIndicator(guild: CombinedGuild, channel: Channel | { id: string }, errorIndicatorElement: HTMLElement): Promise { await this.lockMessages(guild, channel, () => { Q.clearChildren(this.q.$('#channel-feed')); this.q.$('#channel-feed').appendChild(errorIndicatorElement); }); } }