712 lines
35 KiB
TypeScript
712 lines
35 KiB
TypeScript
import * as electronRemote from '@electron/remote';
|
|
const electronConsole = electronRemote.getGlobal('console') as Console;
|
|
import Logger from '../../logger/logger';
|
|
const LOG = new Logger(__filename, electronConsole);
|
|
|
|
import ConcurrentQueue from '../../concurrent-queue/concurrent-queue';
|
|
|
|
import Elements from './elements';
|
|
import ElementsUtil from './elements/require/elements-util';
|
|
|
|
import { $, $$, $$$, $$$$, $_ } from './elements/require/q-module';
|
|
|
|
import Globals from './globals';
|
|
import Util from './util';
|
|
import ClientController from './client-controller';
|
|
import { Message, Member, Channel, ConnectionInfo } from './data-types';
|
|
|
|
class ShouldNeverHappenError extends Error {
|
|
constructor(...args: any[]) {
|
|
super(...args);
|
|
this.name = 'ShouldNeverHappenError';
|
|
}
|
|
}
|
|
|
|
interface SetMessageProps {
|
|
atTop: boolean;
|
|
atBottom: boolean;
|
|
}
|
|
|
|
export default class UI {
|
|
public activeServer: ClientController | { id: null } = { id: null };
|
|
public activeChannel: Channel | { id: null } = { id: null };
|
|
public activeConnection: ConnectionInfo | null = null;
|
|
|
|
public messagesAtTop = false;
|
|
public messagesAtBottom = false;
|
|
|
|
public messagePairsServer: ClientController | { id: null } = { id: null };
|
|
public messagePairsChannel: Channel | { id: null } = { id: null };
|
|
public messagePairs = new Map<string | null, { message: Message, element: HTMLElement }>(); // messageId -> { message: Message, element: HTMLElement }
|
|
|
|
private document: Document;
|
|
|
|
constructor(document: Document) {
|
|
this.document = document;
|
|
}
|
|
|
|
public isActiveServer(server: ClientController): boolean {
|
|
return server.id === this.activeServer.id;
|
|
}
|
|
|
|
public hasActiveServer(): boolean {
|
|
return this.activeServer.id != null;
|
|
}
|
|
|
|
public isActiveChannel(channel: Channel): boolean {
|
|
return channel.id === this.activeChannel.id;
|
|
}
|
|
|
|
public hasActiveChannel(): boolean {
|
|
return this.activeChannel.id != null;
|
|
}
|
|
|
|
public hasActiveConnection(): boolean {
|
|
return this.activeConnection != null;
|
|
}
|
|
|
|
public isMessagePairsServer(server: ClientController): boolean {
|
|
return server.id === this.messagePairsServer.id;
|
|
}
|
|
|
|
public isMessagePairsChannel(channel: Channel): boolean {
|
|
return 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<void>(1);
|
|
private _serverNameLock = 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 _lockWithServer(server: ClientController, task: (() => Promise<void> | void), lock: ConcurrentQueue<void>): Promise<void> {
|
|
if (!this.isActiveServer(server)) return;
|
|
await lock.push(async () => {
|
|
if (!this.isActiveServer(server)) return;
|
|
await task();
|
|
});
|
|
}
|
|
|
|
private async _lockWithServerChannel(server: ClientController, channel: Channel, task: (() => Promise<void> | void), lock: ConcurrentQueue<void>): Promise<void> {
|
|
if (!this.isActiveServer(server)) return;
|
|
if (!this.isActiveChannel(channel)) return;
|
|
await lock.push(async () => {
|
|
if (!this.isActiveServer(server)) return;
|
|
if (!this.isActiveChannel(channel)) return;
|
|
await task();
|
|
});
|
|
}
|
|
|
|
public async lockServerName(server: ClientController, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithServer(server, task, this._serverNameLock);
|
|
}
|
|
|
|
public async lockConnection(server: ClientController, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithServer(server, task, this._connectionLock);
|
|
}
|
|
|
|
public async lockChannels(server: ClientController, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithServer(server, task, this._channelsLock);
|
|
}
|
|
|
|
public async lockMembers(server: ClientController, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithServer(server, task, this._membersLock);
|
|
}
|
|
|
|
public async lockMessages(server: ClientController, channel: Channel, task: (() => Promise<void> | void)): Promise<void> {
|
|
await this._lockWithServerChannel(server, channel, task, this._messagesLock);
|
|
}
|
|
|
|
public setActiveServer(server: ClientController): void {
|
|
$.setDocument(this.document);
|
|
let prev = $('#server-list .server[meta-id="' + this.activeServer.id + '"]');
|
|
if (prev) {
|
|
prev.classList.remove('active');
|
|
}
|
|
let next = $('#server-list .server[meta-id="' + server.id + '"]');
|
|
next.classList.add('active');
|
|
$('#server').setAttribute('meta-id', server.id);
|
|
this.activeServer = server;
|
|
}
|
|
|
|
public async setActiveChannel(server: ClientController, channel: Channel): Promise<void> {
|
|
await this.lockChannels(server, () => {
|
|
$.setDocument(this.document);
|
|
// Channel List Highlight
|
|
let prev = $('#channel-list .channel[meta-id="' + this.activeChannel.id + '"]');
|
|
if (prev) {
|
|
prev.classList.remove('active');
|
|
}
|
|
let next = $('#channel-list .channel[meta-id="' + channel.id + '"]');
|
|
next.classList.add('active');
|
|
|
|
// Channel Name + Flavor Text Header + Text Input Placeholder
|
|
$('#channel-name').innerText = channel.name;
|
|
$('#channel-flavor-text').innerText = channel.flavorText || '';
|
|
$('#channel-flavor-divider').style.visibility = channel.flavorText ? 'visible' : 'hidden';
|
|
$('#text-input').setAttribute('placeholder', 'Message #' + channel.name);
|
|
|
|
this.activeChannel = channel;
|
|
});
|
|
}
|
|
|
|
public async setActiveConnection(server: ClientController, connection: ConnectionInfo): Promise<void> {
|
|
await this.lockConnection(server, () => {
|
|
$.setDocument(this.document);
|
|
this.activeConnection = connection;
|
|
|
|
$('#connection').className = 'member ' + connection.status;
|
|
$('#member-name').innerText = connection.displayName;
|
|
$('#member-status-text').innerText = connection.status;
|
|
|
|
$('#server').className = '';
|
|
for (let privilege of connection.privileges) {
|
|
$('#server').classList.add('privilege-' + privilege);
|
|
}
|
|
|
|
if (connection.displayName) {
|
|
(async () => {
|
|
// Update avatar
|
|
if (!this.isActiveServer(server)) return;
|
|
let src = await ElementsUtil.getImageBufferFromResourceFailSoftly(server, connection.displayName);
|
|
if (!this.isActiveServer(server)) return;
|
|
($('#member-avatar') as HTMLImageElement).src = src;
|
|
})();
|
|
} else {
|
|
($('#member-avatar') as HTMLImageElement).src = './img/loading.svg';
|
|
}
|
|
});
|
|
}
|
|
|
|
public async setServers(servers: ClientController[]): Promise<void> {
|
|
await this._serversLock.push(() => {
|
|
$.setDocument(this.document);
|
|
$.clearChildren($('#server-list'));
|
|
for (let server of servers) {
|
|
let element = Elements.createServerListServer(server);
|
|
$('#server-list').appendChild(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async addServer(server: ClientController): Promise<HTMLElement> {
|
|
let element: HTMLElement | null = null;
|
|
await this._serversLock.push(() => {
|
|
$.setDocument(this.document);
|
|
element = Elements.createServerListServer(server) as HTMLElement;
|
|
$('#server-list').appendChild(element);
|
|
});
|
|
if (element == null) throw new ShouldNeverHappenError('element was not set');
|
|
return element;
|
|
}
|
|
|
|
public async removeServer(server: ClientController): Promise<void> {
|
|
await this._serversLock.push(() => {
|
|
$.setDocument(this.document);
|
|
let element = $_('#server-list .server[meta-id="' + server.id + '"]');
|
|
element?.parentElement?.removeChild(element);
|
|
});
|
|
}
|
|
|
|
public async updateServerIcon(server: ClientController, iconBuff: Buffer): Promise<void> {
|
|
await this._serversLock.push(async () => {
|
|
$.setDocument(this.document);
|
|
let iconElement = $('#server-list .server[meta-id="' + server.id + '"] img') as HTMLImageElement;
|
|
iconElement.src = await ElementsUtil.getImageBufferSrc(iconBuff);
|
|
});
|
|
}
|
|
|
|
public async updateServerName(server: ClientController, name: string): Promise<void>{
|
|
await this.lockServerName(server, () => {
|
|
$.setDocument(this.document);
|
|
$('#server-name').innerText = name;
|
|
let baseElement = $('#server-list .server[meta-id="' + server.id + '"]');
|
|
baseElement.setAttribute('meta-name', name);
|
|
});
|
|
}
|
|
|
|
private _updatePosition(element: HTMLElement, serverCacheMap: Map<string | null, any>, getDirection: ((prevData: any, data: any) => number)) {
|
|
$.setDocument(this.document);
|
|
let data = serverCacheMap.get(element.getAttribute('meta-id'));
|
|
$.assert(data != null, 'unable to get data from server cache map');
|
|
// TODO: do-while may be a bit cleaner?
|
|
let prev = $.previousElement(element);
|
|
while (prev != null) {
|
|
let prevData = serverCacheMap.get(prev.getAttribute('meta-id'));
|
|
if (getDirection(prevData, data) > 0) { // this element comes before previous element
|
|
prev.parentElement?.insertBefore(element, prev);
|
|
} else {
|
|
break;
|
|
}
|
|
prev = $.previousElement(element);
|
|
}
|
|
let next = $.nextElement(element);
|
|
while (next != null) {
|
|
let nextData = serverCacheMap.get(next.getAttribute('meta-id'));
|
|
if (getDirection(data, nextData) > 0) { // this element comes after next element
|
|
next.parentElement?.insertBefore(next, element);
|
|
} else {
|
|
break;
|
|
}
|
|
next = $.nextElement(element);
|
|
}
|
|
}
|
|
|
|
public updateChannelPosition(server: ClientController, channelElement: HTMLElement): void {
|
|
this._updatePosition(channelElement, server.channels, (a, b) => {
|
|
return a.index - b.index;
|
|
});
|
|
}
|
|
|
|
public async addChannels(server: ClientController, channels: Channel[], options?: { clear: boolean }): Promise<void> {
|
|
await this.lockChannels(server, () => {
|
|
$.setDocument(this.document);
|
|
if (options?.clear) {
|
|
$.clearChildren($('#channel-list'));
|
|
}
|
|
for (let channel of channels) {
|
|
let element = Elements.createChannel(server, channel);
|
|
$('#channel-list').appendChild(element);
|
|
this.updateChannelPosition(server, element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async deleteChannels(server: ClientController, channels: Channel[]): Promise<void> {
|
|
await this.lockChannels(server, () => {
|
|
$.setDocument(this.document);
|
|
for (let channel of channels) {
|
|
let element = $_('#channel-list .channel[meta-id="' + channel.id + '"]');
|
|
element?.parentElement?.removeChild(element);
|
|
if (this.isActiveChannel(channel)) {
|
|
this.activeChannel = { id: null };
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public async updateChannels(server: ClientController, data: { oldDataPoint: Channel, newDataPoint: Channel }[]): Promise<void> {
|
|
await this.lockChannels(server, () => {
|
|
$.setDocument(this.document);
|
|
for (const { oldDataPoint, newDataPoint } of data) {
|
|
let newChannel = newDataPoint;
|
|
let oldElement = $('#channel-list .channel[meta-id="' + newChannel.id + '"]');
|
|
let newElement = Elements.createChannel(server, newChannel);
|
|
oldElement.parentElement?.replaceChild(newElement, oldElement);
|
|
this.updateChannelPosition(server, newElement);
|
|
|
|
if (this.isActiveChannel(newChannel)) {
|
|
newElement.classList.add('active');
|
|
|
|
// See also setActiveChannel
|
|
$('#channel-name').innerText = newChannel.name;
|
|
$('#channel-flavor-text').innerText = newChannel.flavorText ?? '';
|
|
$('#channel-flavor-divider').style.visibility = newChannel.flavorText ? 'visible' : 'hidden';
|
|
$('#text-input').setAttribute('placeholder', 'Message #' + newChannel.name);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public async setChannels(server: ClientController, channels: Channel[]): Promise<void> {
|
|
$.setDocument(this.document);
|
|
// 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 = $_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"][meta-server-id="' + this.activeServer.id + '"]');
|
|
await this.addChannels(server, channels, { clear: true });
|
|
if (this.isActiveServer(server)) {
|
|
let newActiveElement = $_('#channel-list .channel[meta-id="' + this.activeChannel.id + '"][meta-server-id="' + this.activeServer.id + '"]');
|
|
if (newActiveElement && oldMatchingElement) {
|
|
let channel = channels.find(channel => channel.id === this.activeChannel.id);
|
|
if (channel === undefined) throw new ShouldNeverHappenError('current channel does not exist in channels list')
|
|
this.setActiveChannel(server, channel);
|
|
} else {
|
|
this.activeChannel = { id: null }; // the active channel was removed
|
|
}
|
|
}
|
|
}
|
|
|
|
public async setChannelsErrorIndicator(server: ClientController, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockChannels(server, () => {
|
|
$.setDocument(this.document);
|
|
$.clearChildren($('#channel-list'));
|
|
$('#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.role_priority == null ? 100 : a.role_priority) - (b.role_priority == null ? 100 : b.role_priority);
|
|
if (rolePriorityCmp != 0) return rolePriorityCmp;
|
|
let statusCmp = statusOrder[a.status] - statusOrder[b.status];
|
|
if (statusCmp != 0) return statusCmp;
|
|
let nameCmp = a.display_name.localeCompare(b.display_name);
|
|
return nameCmp;
|
|
});
|
|
}
|
|
|
|
public async addMembers(server: ClientController, members: Member[], options?: { clear: boolean }): Promise<void> {
|
|
await this.lockMembers(server, () => {
|
|
$.setDocument(this.document);
|
|
if (options?.clear) {
|
|
$.clearChildren($('#server-members'));
|
|
}
|
|
for (let member of members) {
|
|
let element = Elements.createMember(server, member);
|
|
$('#server-members').appendChild(element);
|
|
this.updateMemberPosition(server, element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async deleteMembers(server: ClientController, members: Member[]): Promise<void> {
|
|
await this.lockMembers(server, () => {
|
|
$.setDocument(this.document);
|
|
for (let member of members) {
|
|
let element = $_('#server-members .member[meta-id="' + member.id + '"]');
|
|
element?.parentElement?.removeChild(element);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async updateMembers(server: ClientController, data: { oldDataPoint: Member, newDataPoint: Member }[]): Promise<void> {
|
|
await this.lockMembers(server, () => {
|
|
$.setDocument(this.document);
|
|
for (const { oldDataPoint, newDataPoint } of data) {
|
|
let newMember = newDataPoint;
|
|
let oldElement = $_('#server-members .member[meta-id="' + newMember.id + '"]');
|
|
if (oldElement) {
|
|
let newElement = Elements.createMember(server, newMember);
|
|
oldElement.parentElement?.replaceChild(newElement, oldElement);
|
|
this.updateMemberPosition(server, newElement);
|
|
}
|
|
}
|
|
});
|
|
if (!this.hasActiveChannel()) return;
|
|
await this.lockMessages(server, this.activeChannel as Channel, () => {
|
|
$.setDocument(this.document);
|
|
for (const { oldDataPoint, newDataPoint } of data) {
|
|
let newMember = newDataPoint;
|
|
let newStyle = newMember.roleColor ? 'color: ' + newMember.roleColor : null;
|
|
let newName = newMember.displayName;
|
|
// the extra query selectors may be overkill
|
|
for (let messageElement of $$(`.message[meta-member-id="${newMember.id}"]`)) {
|
|
let nameElement = $$$(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<void> {
|
|
await this.addMembers(server, members, { clear: true });
|
|
}
|
|
|
|
public async setMembersErrorIndicator(server: ClientController, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockMembers(server, () => {
|
|
$.setDocument(this.document);
|
|
$.clearChildren($('#server-members'));
|
|
$('#server-members').appendChild(errorIndicatorElement);
|
|
});
|
|
}
|
|
|
|
public getTopMessagePair(): { message: Message, element: HTMLElement } | null {
|
|
$.setDocument(this.document);
|
|
let element = $$('#channel-feed .message')[0];
|
|
return element && this.messagePairs.get(element.getAttribute('meta-id')) || null;
|
|
}
|
|
|
|
public getBottomMessagePair(): { message: Message, element: HTMLElement } | null {
|
|
$.setDocument(this.document);
|
|
let messageElements = $$('#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<void> {
|
|
this.lockMessages(server, channel, () => {
|
|
$.setDocument(this.document);
|
|
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 = $$('#channel-feed .message');
|
|
$.assert(this.messagePairs.size == currentMessageElements.length, `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($('#channel-feed'), [ 'recent' ]);
|
|
Util.removeErrorIndicators($('#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 = Elements.createMessage(server, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
$('#channel-feed').prepend(element);
|
|
}
|
|
|
|
if (messages.length > 0 && prevTopPair) {
|
|
// Update the previous top message since it may have changed format
|
|
let newPrevTopElement = Elements.createMessage(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, messages: Message[], prevBottomMessage: Message | null): Promise<void> {
|
|
await this.lockMessages(server, channel, () => {
|
|
$.setDocument(this.document);
|
|
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 = $$('#channel-feed .message');
|
|
$.assert(this.messagePairs.size == currentMessageElements.length, '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($('#channel-feed'), [ 'recent' ]);
|
|
Util.removeErrorIndicators($('#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 = Elements.createMessage(server, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
$('#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<void> {
|
|
await this.lockMessages(server, channel, () => {
|
|
$.setDocument(this.document);
|
|
if (!(messages.length > 0 && topElement != null && bottomElement != null && bottomElement == $.nextElement(topElement))) {
|
|
LOG.error('invalid messages between', { messages, top: topElement.innerText, bottom: bottomElement.innerText, afterTop: $.nextElement(topElement)?.innerText });
|
|
throw new Error('invalid messages between');
|
|
}
|
|
|
|
if (this.messagePairs.size + messages.length > Globals.MAX_CURRENT_MESSAGES) {
|
|
let currentMessageElements = $$('#channel-feed .message');
|
|
$.assert(this.messagePairs.size == currentMessageElements.length, '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 > $('#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');
|
|
this.messagePairs.delete(id);
|
|
element.parentElement?.removeChild(element);
|
|
}
|
|
}
|
|
|
|
let topMessage = this.messagePairs.get(topElement.getAttribute('meta-id'))?.message;
|
|
for (let i = 0; i < messages.length; ++i) {
|
|
let message = messages[i];
|
|
let priorMessage = messages[i - 1] || topMessage;
|
|
let element = Elements.createMessage(server, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
$('#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 = Elements.createMessage(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, messages: Message[], props: SetMessageProps): Promise<void> {
|
|
const { atTop, atBottom } = props;
|
|
await this.lockMessages(server, channel, () => {
|
|
$.setDocument(this.document);
|
|
this.messagesAtTop = atTop;
|
|
this.messagesAtBottom = atBottom;
|
|
|
|
Util.removeErrorIndicators($('#channel-feed'), [ 'recent' ]);
|
|
Util.removeErrorIndicators($('#channel-feed'), [ 'before' ]);
|
|
Util.removeErrorIndicators($('#channel-feed'), [ 'after' ]);
|
|
|
|
this.messagePairsServer = server;
|
|
this.messagePairsChannel = channel;
|
|
|
|
this.messagePairs.clear();
|
|
$.clearChildren($('#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 = Elements.createMessage(server, message, priorMessage);
|
|
this.messagePairs.set(message.id, { message: message, element: element });
|
|
$('#channel-feed').prepend(element);
|
|
}
|
|
|
|
this.jumpMessagesToBottom();
|
|
});
|
|
}
|
|
|
|
public jumpMessagesToBottom(): void {
|
|
$.setDocument(this.document);
|
|
$('#channel-feed-content-wrapper').scrollTop = $('#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: { oldDataPoint: Message, newDataPoint: Message }[]): Promise<void> {
|
|
await this.lockMessages(server, channel, () => {
|
|
for (const { oldDataPoint, newDataPoint } of data) {
|
|
let oldMessage = oldDataPoint;
|
|
let newMessage = newDataPoint;
|
|
if (this.messagePairs.has(oldMessage.id)) {
|
|
let oldElement = (this.messagePairs.get(oldMessage.id) as { message: Message, element: HTMLElement }).element;
|
|
let prevElement = $.previousElement(oldElement);
|
|
let prevMessage = prevElement && (this.messagePairs.get(prevElement.getAttribute('meta-id')) as { message: Message, element: HTMLElement }).message;
|
|
let newElement = Elements.createMessage(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<void> {
|
|
await this.lockMessages(server, channel, () => {
|
|
$.setDocument(this.document);
|
|
$('#channel-feed').prepend(errorIndicatorElement);
|
|
});
|
|
}
|
|
|
|
public async addMessagesErrorIndicatorAfter(server: ClientController, channel: Channel, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockMessages(server, channel, () => {
|
|
$.setDocument(this.document);
|
|
$('#channel-feed').appendChild(errorIndicatorElement);
|
|
});
|
|
}
|
|
|
|
public async setMessagesErrorIndicator(server: ClientController, channel: Channel, errorIndicatorElement: HTMLElement): Promise<void> {
|
|
await this.lockMessages(server, channel, () => {
|
|
$.setDocument(this.document);
|
|
$.clearChildren($('#channel-feed'));
|
|
$('#channel-feed').appendChild(errorIndicatorElement);
|
|
});
|
|
}
|
|
}
|