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 ClientController from './client-controller'; import { Message, Member, Channel, ConnectionInfo, ShouldNeverHappenError } from './data-types'; import Q from './q-module'; import createServerListServer from './elements/server-list-server'; import createChannel from './elements/channel'; import createMember from './elements/member'; import Actions from './actions'; import Controller from './controller'; import createMessage from './elements/message'; interface SetMessageProps { atTop: boolean; atBottom: boolean; } export default class UI { public activeServer: ClientController | null = null; public activeChannel: Channel | null = null; public activeConnection: ConnectionInfo | null = null; public messagesAtTop = false; public messagesAtBottom = false; public messagePairsServer: ClientController | 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 isMessagePairsServer(server: ClientController): boolean { return this.messagePairsServer !== null && server.id === this.messagePairsServer.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 _serversLock = new ConcurrentQueue(1); private _serverNameLock = 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 _lockWithServer(server: ClientController, task: (() => Promise | void), lock: ConcurrentQueue): Promise { if (this.activeServer === null || this.activeServer.id !== server.id) return; await lock.push(async () => { if (this.activeServer === null || this.activeServer.id !== server.id) return; await task(); }); } private async _lockWithServerChannel(server: ClientController, channel: Channel | { id: string }, task: (() => Promise | void), lock: ConcurrentQueue): Promise { if (this.activeServer === null || this.activeServer.id !== server.id) return; if (this.activeChannel === null || this.activeChannel.id !== channel.id) return; await lock.push(async () => { if (this.activeServer === null || this.activeServer.id !== server.id) return; if (this.activeChannel === null || this.activeChannel.id !== channel.id) return; await task(); }); } public async lockServerName(server: ClientController, task: (() => Promise | void)): Promise { await this._lockWithServer(server, task, this._serverNameLock); } public async lockConnection(server: ClientController, task: (() => Promise | void)): Promise { await this._lockWithServer(server, task, this._connectionLock); } public async lockChannels(server: ClientController, task: (() => Promise | void)): Promise { await this._lockWithServer(server, task, this._channelsLock); } public async lockMembers(server: ClientController, task: (() => Promise | void)): Promise { await this._lockWithServer(server, task, this._membersLock); } public async lockMessages(server: ClientController, channel: Channel | { id: string }, task: (() => Promise | void)): Promise { await this._lockWithServerChannel(server, channel, task, this._messagesLock); } public setActiveServer(server: ClientController): void { if (this.activeServer !== null) { let prev = this.q.$_('#server-list .server[meta-id="' + this.activeServer.id + '"]'); if (prev) { prev.classList.remove('active'); } } let next = this.q.$('#server-list .server[meta-id="' + server.id + '"]'); next.classList.add('active'); this.q.$('#server').setAttribute('meta-id', server.id); this.activeServer = server; } public async setActiveChannel(server: ClientController, channel: Channel): Promise { await this.lockChannels(server, () => { // 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('placeholder', 'Message #' + channel.name); this.activeChannel = channel; }); } public async setActiveConnection(server: ClientController, connection: ConnectionInfo): Promise { await this.lockConnection(server, () => { 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.$('#server').className = ''; for (let privilege of connection.privileges) { this.q.$('#server').classList.add('privilege-' + privilege); } if (connection.avatarResourceId) { (async () => { // Update avatar if (this.activeServer === null || this.activeServer.id !== server.id) return; let src = await ElementsUtil.getImageBufferFromResourceFailSoftly(server, connection.avatarResourceId); if (this.activeServer === null || this.activeServer.id !== server.id) return; (this.q.$('#member-avatar') as HTMLImageElement).src = src; })(); } else { (this.q.$('#member-avatar') as HTMLImageElement).src = './img/loading.svg'; } }); } public async setServers(actions: Actions, controller: Controller, servers: ClientController[]): Promise { await this._serversLock.push(() => { Q.clearChildren(this.q.$('#server-list')); for (let server of servers) { let element = createServerListServer(this.document, this.q, this, actions, controller, server); this.q.$('#server-list').appendChild(element); } }); } public async addServer(actions: Actions, controller: Controller, server: ClientController): Promise { let element: HTMLElement | null = null; await this._serversLock.push(() => { element = createServerListServer(this.document, this.q, this, actions, controller, server) as HTMLElement; this.q.$('#server-list').appendChild(element); }); if (element == null) throw new ShouldNeverHappenError('element was not set'); return element; } public async removeServer(server: ClientController): Promise { await this._serversLock.push(() => { let element = this.q.$_('#server-list .server[meta-id="' + server.id + '"]'); element?.parentElement?.removeChild(element); }); } public async updateServerIcon(server: ClientController, iconBuff: Buffer): Promise { await this._serversLock.push(async () => { let iconElement = this.q.$('#server-list .server[meta-id="' + server.id + '"] img') as HTMLImageElement; iconElement.src = await ElementsUtil.getImageBufferSrc(iconBuff); }); } public async updateServerName(server: ClientController, name: string): Promise{ await this.lockServerName(server, () => { this.q.$('#server-name').innerText = name; let baseElement = this.q.$('#server-list .server[meta-id="' + server.id + '"]'); baseElement.setAttribute('meta-name', name); }); } private _updatePosition(element: HTMLElement, serverCacheMap: Map, getDirection: ((prevData: T, data: T) => number)) { let data = serverCacheMap.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 = serverCacheMap.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 = serverCacheMap.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 updateChannelPosition(server: ClientController, channelElement: HTMLElement): void { this._updatePosition(channelElement, server.channels, (a, b) => { return a.index - b.index; }); } public async addChannels(actions: Actions, server: ClientController, channels: Channel[], options?: { clear: boolean }): Promise { await this.lockChannels(server, () => { if (options?.clear) { Q.clearChildren(this.q.$('#channel-list')); } for (let channel of channels) { let element = createChannel(this.document, this.q, this, actions, server, channel); this.q.$('#channel-list').appendChild(element); this.updateChannelPosition(server, element); } }); } public async deleteChannels(server: ClientController, channels: Channel[]): Promise { await this.lockChannels(server, () => { 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(actions: Actions, server: ClientController, data: { oldChannel: Channel, newChannel: Channel }[]): Promise { await this.lockChannels(server, () => { 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, actions, server, newChannel); oldElement.parentElement?.replaceChild(newElement, oldElement); this.updateChannelPosition(server, 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(actions: Actions, server: ClientController, channels: Channel[]): Promise { // check if an element with the same channel and server exists before adding the new channels // this is nescessary to make sure that if two servers 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.activeServer !== null && this.activeChannel !== null) { oldMatchingElement = this.q.$_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"][meta-server-id="' + this.activeServer.id + '"]'); } await this.addChannels(actions, server, channels, { clear: true }); if (this.activeServer !== null && this.activeServer.id === server.id && this.activeChannel !== null) { let newActiveElement = this.q.$_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"][meta-server-id="' + this.activeServer.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(server, channel); } else { this.activeChannel = null; // the active channel was removed } } } public async setChannelsErrorIndicator(server: ClientController, errorIndicatorElement: HTMLElement): Promise { await this.lockChannels(server, () => { Q.clearChildren(this.q.$('#channel-list')); this.q.$('#channel-list').appendChild(errorIndicatorElement); }); } public updateMemberPosition(server: ClientController, memberElement: HTMLElement): void { 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, server.members, (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(server: ClientController, members: Member[], options?: { clear: boolean }): Promise { await this.lockMembers(server, () => { if (options?.clear) { Q.clearChildren(this.q.$('#server-members')); } for (let member of members) { let element = createMember(this.q, server, member); this.q.$('#server-members').appendChild(element); this.updateMemberPosition(server, element); } }); } public async deleteMembers(server: ClientController, members: Member[]): Promise { await this.lockMembers(server, () => { for (let member of members) { let element = this.q.$_('#server-members .member[meta-id="' + member.id + '"]'); element?.parentElement?.removeChild(element); } }); } public async updateMembers(server: ClientController, data: { oldMember: Member, newMember: Member }[]): Promise { await this.lockMembers(server, () => { for (const { oldMember, newMember } of data) { let oldElement = this.q.$_('#server-members .member[meta-id="' + newMember.id + '"]'); if (oldElement) { let newElement = createMember(this.q, server, newMember); oldElement.parentElement?.replaceChild(newElement, oldElement); this.updateMemberPosition(server, newElement); } } }); if (this.activeChannel === null) return; await this.lockMessages(server, 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(server: ClientController, members: Member[]): Promise { await this.addMembers(server, members, { clear: true }); } public async setMembersErrorIndicator(server: ClientController, errorIndicatorElement: HTMLElement): Promise { await this.lockMembers(server, () => { Q.clearChildren(this.q.$('#server-members')); this.q.$('#server-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(server: ClientController, channel: Channel, messages: Message[], prevTopMessage: Message | null): Promise { this.lockMessages(server, 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, server, 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, server, 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(server: ClientController, channel: Channel | { id: string }, messages: Message[], prevBottomMessage: Message | null): Promise { await this.lockMessages(server, 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, server, 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(server: ClientController, channel: Channel, messages: Message[], topElement: HTMLElement, bottomElement: HTMLElement): Promise { await this.lockMessages(server, 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, server, 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, server, bottomMessage, messages[messages.length - 1]); bottomElement.parentElement?.replaceChild(newBottomElement, bottomElement); this.messagePairs.set(bottomMessage.id, { element: newBottomElement, message: bottomMessage }); } }); } public async setMessages(server: ClientController, channel: Channel | { id: string }, messages: Message[], props: SetMessageProps): Promise { const { atTop, atBottom } = props; await this.lockMessages(server, 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.messagePairsServer = server; 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, server, 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(server: ClientController, channel: Channel, messages: Message[]) { await this.lockMessages(server, 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(server: ClientController, channel: Channel, data: { oldMessage: Message, newMessage: Message }[]): Promise { await this.lockMessages(server, 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, server, 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(server: ClientController, channel: Channel, errorIndicatorElement: HTMLElement): Promise { await this.lockMessages(server, channel, () => { this.q.$('#channel-feed').prepend(errorIndicatorElement); }); } public async addMessagesErrorIndicatorAfter(server: ClientController, channel: Channel, errorIndicatorElement: HTMLElement): Promise { await this.lockMessages(server, channel, () => { this.q.$('#channel-feed').appendChild(errorIndicatorElement); }); } public async setMessagesErrorIndicator(server: ClientController, channel: Channel | { id: string }, errorIndicatorElement: HTMLElement): Promise { await this.lockMessages(server, channel, () => { Q.clearChildren(this.q.$('#channel-feed')); this.q.$('#channel-feed').appendChild(errorIndicatorElement); }); } }