replaced improper references to 'server' with 'guild' in client

This commit is contained in:
Michael Peters 2021-11-21 21:01:40 -06:00
parent 87a4d8584f
commit ad346fc37d
25 changed files with 92 additions and 1760 deletions

View File

@ -1,944 +0,0 @@
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 * as StackTrace from '../../stack-trace/stack-trace';
import * as crypto from 'crypto';
import { EventEmitter } from 'events';
import * as socketio from 'socket.io-client';
import ConcurrentQueue from '../../concurrent-queue/concurrent-queue';
import { Message, Member, Channel, Changes, ConnectionInfo, CacheServerData, ServerMetaData, ServerConfig, ShouldNeverHappenError, Token } from './data-types';
import DBCache from './db-cache';
import ResourceRAMCache from './resource-ram-cache';
import RecentMessageRAMCache from './message-ram-cache';
// Events:
// 'connected' function() called when connected to the guild
// 'disconnected' function() called when connection to the guild is lost
// 'verified' function() called when verification handshake is completed
// 'update-guild' function(guildMetadata) called when the guild metadata updates
// 'deleted-members' function(members) called when members were deleted on the server-side
// 'updated-members' function(data: Array [ { oldMember, newMember } ]) called when a member was updated on the server-side
// 'added-members' function(members) called when members were added on the server-side
// 'deleted-channels' function(channels) called when channels were deleted on the server-side
// 'updated-channels' function(data: Array [ { oldChannel, newChannel } ]) called when a channel was updated on the server-side
// 'added-channels' function(channels) called when channels are added on the server-side
// 'new-message' function(message) called when a message is received in a channel
// 'deleted-messages' function(messages) called when messages were deleted on the server-side
// 'updated-messages' function(data: Array [ { oldMessage, newMessage } ]) called when messages were updated on the server-side
// 'added-messages' function(addedAfter : Map<messageId -> addedMessage>, addedBefore : <messageId -> addedMessage>) called when messages were added on the server-side (that were not in our client-side db).
// Functions:
// these three have grab counterparts
// async fetchMetadata() Fetches the guild information as { name, icon_resource_id }
// async fetchMembers() Fetches the guild members as [ { id, display_name, avatar_resource_id }, ... ]
// async fetchChannels() Fetches the available channels as [ { id, name }, ... ]
// async fetchMessagesRecent(channelId, number) Fetches the most recent number messages in a channel [ Message, ... ]
// async fetchMessagesBefore(channelId, messageId, number) Fetches number messages before a specified message [ Message, ... ]
// async fetchMessagesAfter(channelId, messageId, number) Fetches number messages after a specified message [ Message, ... ]
// TODO: change this to grab
// async fetchResource(resourceId) Fetches the resource associated with a specified resrouceId
// async sendMessage(channelId, text) Sends a text message to a channel, returning the sent Message
// async sendMessageWithResource(channelId, text, resource, resourceName) Sends a text message with a resource to a channel, returning the sent Message. text is optional
// async setStatus(status) Set the current logged in user's status
// async setDisplayName(displayName) Sets the current logged in user's display name
// async setAvatar(avatarBuff)
// async updateChannel(channelId, name, flavorText) Updates a channel's name and flavor text
// async queryTokens() Queries for the login tokens as [ { token, member_id, created, expires }, ... ]
// async revokeToken(token) Revokes an outstanding token
interface FetchCachedAndVerifyProps<ClientType, ServerType> {
lock?: ConcurrentQueue<void>,
serverFunc: (() => Promise<ServerType>)
cacheFunc: (() => Promise<ClientType | null>),
cacheUpdateFunc: ((cacheData: ClientType | null, serverData: ServerType) => Promise<boolean>), // returns true if changes were made to the cache
updateEventName?: string | null,
}
export default class ClientController extends EventEmitter {
private dbCache: DBCache;
public id: string;
public memberId: string;
public url: string;
public publicKey: crypto.KeyObject;
public privateKey: crypto.KeyObject;
public serverCert: string
public socket: socketio.Socket;
private _metadata: ServerMetaData | CacheServerData | null;
private _recentMessages: RecentMessageRAMCache;
public channels: Map<string, Channel>;
public members: Map<string, Member>;
public channelsLock: ConcurrentQueue<void>;
public membersLock: ConcurrentQueue<void>;
public isVerified: boolean;
public resourceCallbacks: Map<string, ((err: any, resourceBuff: Buffer | null) => Promise<void> | void)[]>;
public dedupedCallbacks: Map<string, (() => Promise<void> | void)[]>;
constructor(dbCache: DBCache, config: ServerConfig) {
super();
this.dbCache = dbCache;
// TODO: fetch these from the cache when they are needed rather than storing them in memory (especially private key)
let publicKey = typeof config.publicKey === 'string' ? crypto.createPublicKey(config.publicKey) : config.publicKey;
let privateKey = typeof config.privateKey === 'string' ? crypto.createPrivateKey(config.privateKey) : config.privateKey;
this.id = config.guildId + ''; // this is the client-side server id (from Cache)
this.memberId = config.memberId;
this.url = config.url;
this.publicKey = publicKey;
this.privateKey = privateKey;
this.serverCert = config.serverCert;
this.socket = socketio.connect(this.url, {
forceNew: true,
ca: this.serverCert, // this provides identity verification for the server
});
this._metadata = null; // use grabMetadata();
this._recentMessages = new RecentMessageRAMCache(); // use grabRecentMessages();
// These are used to make message objects more useful
// TODO: make these private
this.channels = new Map<string, Channel>(); // use grabChannels();
this.members = new Map<string, Member>(); // use grabMembers();
this.channelsLock = new ConcurrentQueue<void>(1);
this.membersLock = new ConcurrentQueue<void>(1);
this.isVerified = false;
this.resourceCallbacks = new Map<string, ((err: any, resourceBuff: Buffer | null) => Promise<void> | void)[]>(); // resourceId -> [ callbackFunc, ... ];
this.dedupedCallbacks = new Map<string, (() => Promise<void> | void)[]>(); // dedupeId -> [ callbackFunc, ... ];
this._bindSocket();
this._bindInternalEvents();
}
_bindSocket(): void {
this.socket.on('connect', async () => {
LOG.info('connected to server#' + this.id);
this.emit('connected');
// TODO: re-verify on verification failure?
await this.verify();
});
this.socket.on('disconnect', () => {
LOG.info('disconnected from server#' + this.id);
this.isVerified = false;
this._metadata = null;
this._recentMessages.clear();
this.channels.clear();
this.members.clear();
this.emit('disconnected');
});
this.socket.on('new-message', async (dataMessage) => {
await this.ensureMembers();
await this.ensureChannels();
let message = Message.fromDBData(dataMessage, this.members, this.channels);
await this.dbCache.upsertServerMessages(this.id, message.channel.id, [ message ]);
LOG.info(message.toString());
this._recentMessages.addNewMessage(this.id, message);
this.emit('new-message', message);
});
this.socket.on('update-member', async (member) => {
await this.ensureMembers();
let oldMember = this.members.get(member.id);
if (oldMember) {
this.emit('updated-members', [ { oldMember: oldMember, newMember: member } ]);
} else {
this.emit('added-members', [ member ]);
}
});
this.socket.on('update-channel', async (channel) => {
await this.ensureChannels();
let oldChannel = this.channels.get(channel.id);
if (oldChannel) {
this.emit('updated-channels', [ { oldChannel: oldChannel, newChannel: channel } ]);
} else {
this.emit('added-channels', [ channel ]);
}
});
this.socket.on('new-channel', async (channel) => {
await this.ensureChannels();
this.emit('added-channels', [ channel ]);
});
this.socket.on('update-server', async (serverMeta) => {
await this.dbCache.updateServer(this.id, serverMeta);
this.emit('update-server', serverMeta);
});
}
_bindInternalEvents(): void {
this.on('added-members', async (members: Member[]) => {
for (let member of members) {
this.members.set(member.id, member);
}
await this.dbCache.updateServerMembers(this.id, Array.from(this.members.values()));
});
this.on('updated-members', async (data: { oldMember: Member, newMember: Member }[]) => {
for (const { oldMember, newMember } of data) {
this.members.set(newMember.id, newMember);
}
await this.dbCache.updateServerMembers(this.id, Array.from(this.members.values()));
});
this.on('deleted-members', async (members: Member[]) => {
for (let member of members) {
this.members.delete(member.id);
}
await this.dbCache.updateServerMembers(this.id, Array.from(this.members.values()));
});
this.on('added-channels', async (channels: Channel[]) => {
for (let channel of channels) {
this.channels.set(channel.id, channel);
}
await this.dbCache.updateServerChannels(this.id, Array.from(this.channels.values()));
});
this.on('updated-channels', async (data: { oldChannel: Channel, newChannel: Channel }[]) => {
for (const { oldChannel, newChannel } of data) {
this.channels.set(newChannel.id, newChannel);
}
await this.dbCache.updateServerChannels(this.id, Array.from(this.channels.values()));
});
this.on('deleted-channels', async (channels: Channel[]) => {
for (let channel of channels) {
this.channels.delete(channel.id);
}
await this.dbCache.updateServerChannels(this.id, Array.from(this.channels.values()));
});
this.on('added-messages', async (channel: Channel, addedAfter: Map<string, Message>, addedBefore: Map<string, Message>) => {
// Adding messages is surprisingly complicated (see script.js's added-messages function)
// so we can just drop the channel and refresh it once if any messages got added while
// we were gone. Further, it is probably best to make 100% sure that the script.js
// implementation is correct before copying it elsewhere. (I'm 90% sure it is correct)
// Getting this correct and actually implementing the added-messages feature in the
// RAM cache would be useful for first-time joiners to a server, getting the channel
// messages for the first time
// Alternatively, just store the date in the message and use order-by
this._recentMessages.dropChannel(this.id, channel.id);
await this.dbCache.upsertServerMessages(this.id, channel.id, Array.from(addedAfter.values()));
});
this.on('updated-messages', async (channel: Channel, data: { oldMessage: Message, newMessage: Message }[]) => {
for (let { oldMessage, newMessage } of data) {
this._recentMessages.updateMessage(this.id, oldMessage, newMessage);
}
await this.dbCache.upsertServerMessages(this.id, channel.id, data.map(change => change.newMessage));
});
this.on('deleted-messages', async (_channel: Channel, messages: Message[]) => {
for (let message of messages) {
this._recentMessages.deleteMessage(this.id, message);
}
await this.dbCache.deleteServerMessages(this.id, messages.map(message => message.id));
});
}
_updateCachedMembers(members: Member[]): void {
this.members.clear();
for (let member of members) {
this.members.set(member.id, member);
}
}
_updateCachedChannels(channels: Channel[]): void {
this.channels.clear();
for (let channel of channels) {
this.channels.set(channel.id, channel);
}
}
/**
* Fetches data from the server but returns cached data if it already exists. If the server data comes back different
* from the cached data, emits an event with the server data
* @param lock A locking enqueue function to make sure that the cache data does not change while waiting for the server data to be requested.
* @param serverFunc A function() that returns the data from the server
* @param cacheFunc A function() that returns the data from the cache (returns null if no data)
* @param cacheUpdateFunc A function(cacheData, serverData) is called after the server data is fetched. It should be used to update the data in the cache
* @param updateEventName The name of the event to emit when the server data is different from the cache data (and the cache data is not null), null for no event
*/
async _fetchCachedAndVerify<T, U>(props: FetchCachedAndVerifyProps<T, U>): Promise<T | U> {
const { lock, serverFunc, cacheFunc, cacheUpdateFunc, updateEventName } = props;
return await new Promise(async (resolve, reject) => {
let func = async () => {
try {
let serverPromise = serverFunc();
let cacheData = await cacheFunc();
if (cacheData !== null) {
resolve(cacheData);
}
let serverData: U;
try {
serverData = await serverPromise;
} catch (e) {
if (cacheData !== null) {
// Print an error if this was already resolved
LOG.warn('Error fetching server data:', e);
return;
} else {
throw e;
}
}
// make sure the 'added/deleted/updated' events get run before returning the server data
try {
let changesMade = await cacheUpdateFunc(cacheData, serverData);
if (updateEventName && cacheData != null && changesMade) {
this.emit(updateEventName, serverData);
}
} catch (e) {
LOG.error('error handling cache update', e);
}
if (cacheData == null) {
resolve(serverData);
}
} catch (e) {
reject(e);
}
};
if (lock) {
await lock.push(func);
} else {
await func();
}
});
}
// This function is a promise for error stack tracking purposes.
async _socketEmitTimeout(ms: number, name: string, ...args: any[]): Promise<void> {
return new Promise<void>((resolve) => {
let socketArgs = args.slice(0, args.length - 1);
let respond = args[args.length - 1];
let cutoff = false;
let timeout = setTimeout(() => {
cutoff = true;
respond('emit timeout');
resolve();
}, ms);
this.socket.emit(name, ...socketArgs, (...respondArgs: any[]) => {
if (cutoff) {
return;
}
clearTimeout(timeout);
respond(...respondArgs);
resolve();
});
});
}
/**
* Queries data from the server
* @param endpoint The server-side socket endpoint
* @param args The server socket arguments
*/
async _queryServer(endpoint: string, ...args: any[]): Promise<any> {
// NOTE: socket.io may cause client-side memory leaks if the ack function is never called
await this.ensureVerified(5000);
let message = `querying s#${this.id} @${endpoint}(${args.map(arg => LOG.inspect(arg)).join(', ')})`;
LOG.silly(message);
//if (endpoint === 'fetch-messages-recent') LOG.silly(null, new Error('call stack'));
return await new Promise((resolve, reject) => {
this._socketEmitTimeout(5000, endpoint, ...args, async (errMsg: string, serverData: any) => {
if (errMsg) {
reject(new Error('error fetching server data @' + endpoint + ' / [' + args.map(arg => LOG.inspect(arg)).join(', ') + ']: ' + errMsg));
} else {
resolve(serverData);
}
});
});
}
// TODO: Make this "T extends something with an id"
static _getChanges<T>(cacheData: T[] | null, serverData: T[], equal: ((a: T, b: T) => boolean)): Changes<T> {
if (cacheData === null) {
return { updated: [], added: serverData, deleted: [] };
}
let updated: { oldDataPoint: T, newDataPoint: T }[] = [];
let added: T[] = [];
let deleted: T[] = [];
for (let serverDataPoint of serverData) {
let cacheDataPoint = cacheData.find((m: T) => (m as any).id == (serverDataPoint as any).id);
if (cacheDataPoint) {
if (!equal(cacheDataPoint, serverDataPoint)) {
updated.push({ oldDataPoint: cacheDataPoint, newDataPoint: serverDataPoint });
}
} else {
added.push(serverDataPoint);
}
}
for (let cacheDataPoint of cacheData) {
let serverDataPoint = serverData.find((s: T) => (s as any).id == (cacheDataPoint as any).id);
if (serverDataPoint == null) {
deleted.push(cacheDataPoint);
}
}
return { updated, added, deleted };
}
disconnect(): void {
this.socket.disconnect();
}
async verify(): Promise<void> {
await new Promise<void>(async (resolve, reject) => {
// Solve the server's challenge
let publicKeyBuff = this.publicKey.export({ type: 'spki', format: 'der' });
this._socketEmitTimeout(5000, 'challenge', publicKeyBuff, (errMsg, algo, type, challenge) => {
if (errMsg) {
reject(new Error('challenge request failed: ' + errMsg));
return;
}
const sign = crypto.createSign(algo);
sign.write(challenge);
sign.end();
let signature = sign.sign(this.privateKey, type);
this._socketEmitTimeout(5000, 'verify', signature, (errMsg, memberId) => {
if (errMsg) {
reject(new Error('verification request failed: ' + errMsg));
return;
}
this.memberId = memberId;
this.dbCache.updateServerMemberId(this.id, this.memberId);
this.isVerified = true;
LOG.info(`verified at server#${this.id} as u#${this.memberId}`);
resolve();
this.emit('verified');
});
});
});
}
// timeout is in ms
async ensureVerified(timeout: number): Promise<void> {
if (this.isVerified) {
return;
}
await new Promise<void>((resolve, reject) => {
let timeoutId: any = null;
let listener = () => {
clearTimeout(timeoutId);
resolve();
}
this.once('verified', listener);
timeoutId = setTimeout(() => {
this.off('verified', listener);
reject(new Error('verification timeout'));
}, timeout);
});
}
async ensureMetadata(): Promise<void> {
if (this._metadata !== null) return;
await this.fetchMetadata();
}
async ensureMembers(): Promise<void> {
if (this.members.size > 0) return;
await this.fetchMembers();
}
async ensureChannels(): Promise<void> {
if (this.channels.size > 0) return;
await this.fetchChannels();
}
async getMyMember(): Promise<Member> {
await this.ensureMembers();
return this.members.get(this.memberId) as Member;
}
async grabMetadata(): Promise<ServerMetaData | CacheServerData> {
await this.ensureMetadata();
return this._metadata as ServerMetaData | CacheServerData;
}
async grabMembers(): Promise<Member[]> {
await this.ensureMembers();
return Array.from(this.members.values());
}
async grabChannels(): Promise<Channel[]> {
await this.ensureChannels();
return Array.from(this.channels.values());
}
async grabRecentMessages(channelId: string, number: number): Promise<Message[]> {
let cached = this._recentMessages.getRecentMessages(this.id, channelId, number);
if (cached !== null) return cached;
return await this.fetchMessagesRecent(channelId, number);
}
async fetchMetadata(): Promise<ServerMetaData | CacheServerData> {
function isDifferent(cacheData: CacheServerData | null, serverData: ServerMetaData) {
if (cacheData === null) return true;
return !!(cacheData.name != serverData.name || cacheData.iconResourceId != serverData.iconResourceId)
}
let metadata = await this._fetchCachedAndVerify<CacheServerData, ServerMetaData>({
serverFunc: async () => { return ServerMetaData.fromServerDBData(await this._queryServer('fetch-server')); },
cacheFunc: async () => { return await this.dbCache.getServer(this.id); },
cacheUpdateFunc: async (cacheData: CacheServerData | null, serverData: ServerMetaData) => {
if (!isDifferent(cacheData, serverData)) return false;
await this.dbCache.updateServer(this.id, serverData);
return true;
},
updateEventName: 'update-server',
});
this._metadata = metadata as ServerMetaData | CacheServerData;
return metadata;
}
// if not verified, will attempt to load from cache rather than waiting for verification
// returns { avatar_resource_id, display_name, status }
async fetchConnectionInfo(): Promise<ConnectionInfo> {
let connection: ConnectionInfo = {
id: null,
avatarResourceId: null,
displayName: 'Connecting...',
status: '',
privileges: [],
roleName: null,
roleColor: null,
rolePriority: null
}
if (this.isVerified) {
await this.ensureMembers();
let member = this.members.get(this.memberId);
if (member) {
connection.id = member.id;
connection.avatarResourceId = member.avatarResourceId;
connection.displayName = member.displayName;
connection.status = member.status;
connection.roleName = member.roleName;
connection.roleColor = member.roleColor;
connection.rolePriority = member.rolePriority;
connection.privileges = member.privileges;
} else {
LOG.warn('Unable to find self in members', this.members);
}
} else {
let cacheMembers = await this.dbCache.getMembers(this.id);
if (cacheMembers) {
let member = cacheMembers.find(m => m.id == this.memberId);
if (member) {
connection.id = member.id;
connection.avatarResourceId = member.avatarResourceId;
connection.displayName = member.displayName;
connection.status = 'connecting';
connection.privileges = [];
} else {
LOG.warn('Unable to find self in cached members');
}
}
}
return connection;
}
async fetchMembers(): Promise<Member[]> {
let members = await this._fetchCachedAndVerify<Member[], Member[]>({
lock: this.membersLock,
serverFunc: async () => {
let dataMembers = (await this._queryServer('fetch-members')) as any[];
return dataMembers.map((dataMember: any) => Member.fromDBData(dataMember));
},
cacheFunc: async () => { return await this.dbCache.getMembers(this.id); },
cacheUpdateFunc: async (cacheData: Member[] | null, serverData: Member[]) => {
function equal(cacheMember: Member, serverMember: Member) {
return (
cacheMember.id === serverMember.id &&
cacheMember.displayName === serverMember.displayName &&
cacheMember.status === serverMember.status &&
cacheMember.avatarResourceId === serverMember.avatarResourceId &&
cacheMember.roleName === serverMember.roleName &&
cacheMember.roleColor === serverMember.roleColor &&
cacheMember.rolePriority === serverMember.rolePriority &&
cacheMember.privileges.join(',') === serverMember.privileges.join(',')
);
}
let changes = ClientController._getChanges<Member>(cacheData, serverData, equal);
if (changes.updated.length == 0 && changes.added.length == 0 && changes.deleted.length == 0) {
return false;
}
await this.dbCache.updateServerMembers(this.id, serverData);
this._updateCachedMembers(serverData);
if (changes.deleted.length > 0) {
this.emit('deleted-members', changes.deleted);
}
if (changes.added.length > 0) {
this.emit('added-members', changes.added);
}
if (changes.updated.length > 0) {
this.emit('updated-members', changes.updated.map(change => ({ oldMember: change.oldDataPoint, newMember: change.newDataPoint })));
}
return true;
},
updateEventName: null,
});
this._updateCachedMembers(members);
return members;
}
async fetchChannels(): Promise<Channel[]> {
let changes: Changes<Channel> | null = null;
let channels = await this._fetchCachedAndVerify<Channel[], Channel[]>({
lock: this.channelsLock,
serverFunc: async () => {
let dataChannels = (await this._queryServer('fetch-channels')) as any[];
return dataChannels.map((dataChannel: any) => Channel.fromDBData(dataChannel));
},
cacheFunc: async () => { return await this.dbCache.getChannels(this.id); },
cacheUpdateFunc: async (cacheData: Channel[] | null, serverData: Channel[]) => {
function equal(cacheChannel: Channel, serverChannel: Channel) {
return cacheChannel.id == serverChannel.id &&
cacheChannel.index == serverChannel.index &&
cacheChannel.name == serverChannel.name &&
cacheChannel.flavorText == serverChannel.flavorText;
}
let changes = ClientController._getChanges(cacheData, serverData, equal);
if (changes.updated.length == 0 && changes.added.length == 0 && changes.deleted.length == 0) {
return false;
}
// All cache updates are handled by internal event handlers
if (changes.deleted.length > 0) {
this.emit('deleted-channels', changes.deleted);
}
if (changes.added.length > 0) {
this.emit('added-channels', changes.added);
}
if (changes.updated.length > 0) {
this.emit('updated-channels', changes.updated.map(change => ({ oldChannel: change.oldDataPoint, newChannel: change.newDataPoint })));
}
return true;
},
updateEventName: null,
});
this._updateCachedChannels(channels);
return channels;
}
// channelId: the id of the channel the messages were fetched from (used in the events)
// firstMessageId: the first message in the current list of messages
// lastMessageId: the last element in the current list of messages
private async updateMessageCache(
channelId: string,
firstMessageId: string | null,
lastMessageId: string | null,
cacheMessages: Message[] | null,
serverMessages: Message[]
): Promise<boolean> {
function equal(cacheMessage: Message, serverMessage: Message) {
return cacheMessage.id == serverMessage.id &&
cacheMessage.channel.id == serverMessage.channel.id &&
cacheMessage.member.id == serverMessage.member.id &&
cacheMessage.sent.getTime() == serverMessage.sent.getTime() &&
cacheMessage.text == serverMessage.text &&
cacheMessage.resourceId == serverMessage.resourceId &&
cacheMessage.resourceName == serverMessage.resourceName &&
cacheMessage.resourceWidth == serverMessage.resourceWidth &&
cacheMessage.resourceHeight == serverMessage.resourceHeight &&
cacheMessage.resourcePreviewId == serverMessage.resourcePreviewId
}
let diffFound = false;
let updatedMessages: { oldMessage: Message, newMessage: Message }[] = [];
let addedAfter = new Map<string, Message>(); // messageId -> message added after this message
let addedBefore = new Map<string, Message>(); // messageId -> message added before this message
for (let i = 0; i < serverMessages.length; ++i) {
let serverMessage = serverMessages[i];
let cacheMessage = cacheMessages?.find(m => serverMessage.id == m.id);
if (cacheMessage) {
if (!equal(cacheMessage, serverMessage)) {
diffFound = true;
updatedMessages.push({
oldMessage: cacheMessage,
newMessage: serverMessage,
});
}
} else {
// items in server not in cache are added
diffFound = true;
let comesAfter = serverMessages[i - 1] || { id: lastMessageId }; // this message comesAfter comesAfter
let comesBefore = serverMessages[i + 1] || { id: firstMessageId }; // this message comesBefore comesBefore
addedAfter.set(comesAfter.id, serverMessage);
addedBefore.set(comesBefore.id, serverMessage);
}
}
let deletedMessages: Message[] = [];
if (cacheMessages !== null) {
for (let cacheMessage of cacheMessages) {
let serverMessage = serverMessages.find(m => cacheMessage.id == m.id);
if (serverMessage == null) {
// items in cache not in server are deleted
diffFound = true;
deletedMessages.push(cacheMessage);
}
}
}
// Send out the events and update the cache
if (cacheMessages !== null) {
if (cacheMessages.length > 0 && deletedMessages.length === cacheMessages.length) {
// if all of the cache data was invalid, it is likely that it needs to be cleared
// this typically happens when the server got a lot of new messages since the cache
// was last updated
await this.dbCache.clearServerMessages(this.id, deletedMessages[0].channel.id);
} else if (deletedMessages.length > 0) {
// Messages from the cache that come on the far side of the request are marked as deleted
// so they are deleted from the UI. However, they should not be removed from the cache
// yet since they most likely have not been pulled from the server yet. (and will be
// very likely pulled from the server on the next fetch).
let cacheDeletedMessages = deletedMessages.slice();
let i: number | null = null;
if (firstMessageId || (firstMessageId == null && lastMessageId == null)) { // before & recent
i = 0;
while (cacheDeletedMessages.length > 0 && cacheDeletedMessages[0].id == cacheMessages[i].id) {
cacheDeletedMessages.shift();
i++;
}
}
if (lastMessageId) { // after
i = 0;
while (cacheDeletedMessages.length > 0 && cacheDeletedMessages[cacheDeletedMessages.length - 1].id == cacheMessages[cacheMessages.length - 1 - i].id) {
cacheDeletedMessages.pop();
i++;
}
}
//LOG.debug('skipping ' + i + ' deleted messages on the cache side -> deleting ' + cacheDeletedMessages.length + ' cache messages instead of ' + deletedMessages.length);
if (cacheDeletedMessages.length > 0) {
await this.dbCache.deleteServerMessages(this.id, cacheDeletedMessages.map(m => m.id));
}
}
}
if (deletedMessages.length > 0) { // make sure to do deleted before added
this.emit('deleted-messages', this.channels.get(channelId), deletedMessages);
}
if (updatedMessages.length > 0) {
this.emit('updated-messages', this.channels.get(channelId), updatedMessages);
}
if (addedAfter.size > 0 || addedBefore.size > 0) {
this.emit('added-messages', this.channels.get(channelId), addedAfter, addedBefore);
}
return diffFound;
}
async fetchMessagesRecent(channelId: string, number: number): Promise<Message[]> {
await this.ensureMembers();
await this.ensureChannels();
let messages = await this._fetchCachedAndVerify<Message[], Message[]>({
serverFunc: async () => {
let dataMessages = await this._queryServer('fetch-messages-recent', channelId, number) as any[];
return dataMessages.map((dataMessage: any) => Message.fromDBData(dataMessage, this.members, this.channels));
},
cacheFunc: async () => {
return await this.dbCache.getMessagesRecent(this.id, channelId, number, this.members, this.channels);
},
cacheUpdateFunc: async (cacheData: Message[] | null, serverData: Message[]) => {
return await this.updateMessageCache(channelId, null, null, cacheData, serverData);
},
updateEventName: null // all events are handled in diffFunc
});
this._recentMessages.putRecentMessages(this.id, channelId, messages);
return messages;
}
async fetchMessagesBefore(channelId: string, messageId: string, number: number): Promise<Message[] | null> {
await this.ensureMembers();
await this.ensureChannels();
let messages = await this._fetchCachedAndVerify<Message[], Message[] | null>({
serverFunc: async () => {
let dataMessages = await this._queryServer('fetch-messages-before', channelId, messageId, number) as any[];
return dataMessages.map((dataMessage: any) => Message.fromDBData(dataMessage, this.members, this.channels));
},
cacheFunc: async () => {
return await this.dbCache.getMessagesBefore(this.id, channelId, messageId, number, this.members, this.channels);
},
cacheUpdateFunc: async (cacheData: Message[] | null, serverData: Message[]) => {
return await this.updateMessageCache(channelId, messageId, null, cacheData, serverData);
},
updateEventName: null // all events are handled in diffFunc
});
return messages;
}
async fetchMessagesAfter(channelId: string, messageId: string, number: number): Promise<Message[] | null> {
await this.ensureMembers();
await this.ensureChannels();
let messages = await this._fetchCachedAndVerify<Message[], Message[] | null>({
serverFunc: async () => {
let dataMessages = await this._queryServer('fetch-messages-after', channelId, messageId, number) as any[];
return dataMessages.map((dataMessage: any) => Message.fromDBData(dataMessage, this.members, this.channels));
},
cacheFunc: async () => { return await this.dbCache.getMessagesAfter(this.id, channelId, messageId, number, this.members, this.channels); },
cacheUpdateFunc: async (cacheData: Message[] | null, serverData: Message[]) => {
return await this.updateMessageCache(channelId, null, messageId, cacheData, serverData);
},
updateEventName: null // all events are handled in diffFunc
});
return messages;
}
async _fetchResourceInternal(resourceId: string): Promise<Buffer> {
// not using standard _fetchCached here because server-side resources never change.
// rather, the resource_id would be updated if it changes for a message, server icon, avatar, etc.
// this provides for a much simpler cache system (3 stages, client-memory, client-db, server-side)
// since all guildId / resourceId pairs will never update their resource buffers, this cache becomes exceedingly simple
let resourceCacheDataBuff = ResourceRAMCache.getResource(this.id, resourceId);
if (resourceCacheDataBuff != null) {
return resourceCacheDataBuff;
}
let cacheData = await this.dbCache.getResource(this.id, resourceId);
if (cacheData !== null) {
ResourceRAMCache.putResource(this.id, resourceId, cacheData.data);
return cacheData.data;
}
// Note: Not pre-requesting from server asynchronously to reduce fetch-resource requests
let serverData = await this._queryServer('fetch-resource', resourceId);
ResourceRAMCache.putResource(this.id, resourceId, serverData.data);
await this.dbCache.upsertServerResources(this.id, [ serverData ]);
return serverData.data;
}
/**
* Deduplicates client-side resource requests. Useful for when the client wants to fetch an avatar
* multiple times for the channel feed. Or if there is a duplicate image in the feed.
* @param resourceId
*/
async fetchResource(resourceId: string): Promise<Buffer> {
return await new Promise(async (resolve, reject) => {
let resultFunc = (err: any, resourceBuff: Buffer | null) => {
if (err) {
reject(err);
} else if (resourceBuff) {
resolve(resourceBuff);
} else {
reject(new ShouldNeverHappenError('no buffer or error!'));
}
}
if (this.resourceCallbacks.has(resourceId)) {
this.resourceCallbacks.get(resourceId)?.push(resultFunc);
} else {
this.resourceCallbacks.set(resourceId, [ resultFunc ]);
let result: Buffer | null = null;
let err: any = null;
try {
result = await this._fetchResourceInternal(resourceId);
} catch (e) {
err = e;
}
for (let callbackFunc of this.resourceCallbacks.get(resourceId) ?? []) {
callbackFunc(err, result);
}
this.resourceCallbacks.delete(resourceId);
}
});
}
async sendMessage(channelId: string, text: string) {
let dataMessage = await this._queryServer('send-message', channelId, text);
await this.ensureMembers();
await this.ensureChannels();
return Message.fromDBData(dataMessage, this.members, this.channels);
}
async sendMessageWithResource(channelId: string, text: string | null, resourceBuff: Buffer, resourceName: string) {
let dataMessage = await this._queryServer('send-message-with-resource', channelId, text, resourceBuff, resourceName);
await this.ensureMembers();
await this.ensureChannels();
return Message.fromDBData(dataMessage, this.members, this.channels);
}
async setStatus(status: string): Promise<void> {
// wow, that's simple for a change
await this._queryServer('set-status', status);
}
async setDisplayName(displayName: string): Promise<void> {
await this._queryServer('set-display-name', displayName);
}
async setAvatar(avatarBuff: Buffer): Promise<void> {
await this._queryServer('set-avatar', avatarBuff);
}
async setName(name: string): Promise<ServerMetaData> {
return ServerMetaData.fromServerDBData(await this._queryServer('set-name', name));
}
async setIcon(iconBuff: Buffer): Promise<ServerMetaData> {
return ServerMetaData.fromServerDBData(await this._queryServer('set-icon', iconBuff));
}
async updateChannel(channelId: string, name: string, flavorText: string | null): Promise<Channel> {
return Channel.fromDBData(await this._queryServer('update-channel', channelId, name, flavorText));
}
async createChannel(name: string, flavorText: string | null): Promise<Channel> {
return Channel.fromDBData(await this._queryServer('create-text-channel', name, flavorText));
}
async queryTokens(): Promise<Token[]> {
// No cacheing for now, this is a relatively small request and comes
// after a context-menu click so it's not as important to cache as the
// channels, members, and messages
let dataTokens = await this._queryServer('fetch-tokens');
await this.ensureMembers();
return dataTokens.map(dataToken => {
return Token.fromDBData(dataToken, this.members);
});
}
async revokeToken(token: string): Promise<void> {
return await this._queryServer('revoke-token', token);
}
close(): void {
this.socket.close();
}
}

View File

@ -1,522 +0,0 @@
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 * as fs from 'fs/promises';
import ConcurrentQueue from '../../concurrent-queue/concurrent-queue';
import Globals from './globals';
import * as sqlite from 'sqlite';
import * as sqlite3 from 'sqlite3';
import { Message, Member, Channel, Resource, ServerMetaData, ServerConfig, CacheServerData } from './data-types';
// A cache implemented using an sqlite database
// Also stores configuration for server connections
export default class DBCache {
private TRANSACTION_QUEUE = new ConcurrentQueue(1);
private constructor(
private readonly db: sqlite.Database
) {}
async beginTransaction(): Promise<void> {
await this.db.run('BEGIN TRANSACTION');
}
async rollbackTransaction(): Promise<void> {
await this.db.run('ROLLBACK');
}
async commitTransaction(): Promise<void> {
await this.db.run('COMMIT');
}
async queueTransaction(func: (() => Promise<void>)): Promise<void> {
await this.TRANSACTION_QUEUE.push(async () => {
try {
await this.beginTransaction();
await func();
await this.commitTransaction();
} catch (e) {
await this.rollbackTransaction();
throw e;
}
});
}
static async connect(): Promise<DBCache> {
try {
await fs.access('./db');
} catch (e) {
await fs.mkdir('./db');
}
return new DBCache(await sqlite.open({
driver: sqlite3.Database,
filename: './db/cache.db'
}));
}
async init(): Promise<void> {
await this.queueTransaction(async () => {
await this.db.run(`
CREATE TABLE IF NOT EXISTS identities (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT
, public_key TEXT NOT NULL
, private_key TEXT NOT NULL
)
`);
await this.db.run(`
CREATE TABLE IF NOT EXISTS servers (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT
, url TEXT NOT NULL
, cert TEXT NOT NULL
, name TEXT
, icon_resource_id TEXT
, member_id TEXT
)
`);
await this.db.run(`
CREATE TABLE IF NOT EXISTS server_identities (
server_id INTEGER NOT NULL
, identity_id INTEGER NOT NULL
, FOREIGN KEY (server_id) REFERENCES servers(id)
, FOREIGN KEY (identity_id) REFERENCES identities(id)
)
`);
await this.db.run(`
CREATE TABLE IF NOT EXISTS members (
id TEXT NOT NULL
, server_id INTEGER NOT NULL REFERENCES servers(id)
, display_name TEXT NOT NULL
, status TEXT NOT NULL
, avatar_resource_id TEXT NOT NULL
, role_name TEXT
, role_color TEXT
, role_priority INTEGER
, privileges TEXT
, CONSTRAINT members_id_server_id_con UNIQUE (id, server_id)
)
`);
await this.db.run(`
CREATE TABLE IF NOT EXISTS channels (
id TEXT NOT NULL
, server_id INTEGER NOT NULL REFERENCES servers(id)
, "index" INTEGER NOT NULL
, name TEXT NOT NULL
, flavor_text TEXT
, CONSTRAINT channels_id_server_id_con UNIQUE (id, server_id)
)
`);
await this.db.run(`
CREATE TABLE IF NOT EXISTS resources (
id TEXT NOT NULL
, server_id INTEGER NOT NULL REFERENCES servers(id)
, hash BLOB NOT NULL
, data BLOB NOT NULL
, data_size INTEGER NOT NULL
, last_used INTEGER NOT NULL
, CONSTRAINT resources_id_server_id_con UNIQUE (id, server_id)
)
`);
await this.db.run('CREATE INDEX IF NOT EXISTS resources_data_size_idx ON resources (data_size)');
// note: no foreign key on resource_id since we may not have cached the resource yet
await this.db.run(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT NOT NULL
, server_id INTEGER NOT NULL REFERENCES servers(id)
, channel_id TEXT NOT NULL REFERENCES channels(id)
, member_id TEXT NOT NULL REFERENCES members(id)
, sent_dtg INTEGER NOT NULL
, text TEXT
, resource_id TEXT
, resource_name TEXT
, resource_width INTEGER
, resource_height INTEGER
, resource_preview_id TEXT
, CONSTRAINT messages_id_server_id_con UNIQUE (id, server_id)
)
`);
await this.db.run('CREATE INDEX IF NOT EXISTS messages_id_idx ON messages (id)');
await this.db.run('CREATE INDEX IF NOT EXISTS messages_sent_dtg_idx ON messages (sent_dtg)');
});
}
async close(): Promise<void> {
await this.db.close();
}
// dangerous!
async reset(): Promise<void> {
await this.queueTransaction(async () => {
await this.db.run('DROP TABLE IF EXISTS identities');
await this.db.run('DROP TABLE IF EXISTS servers');
await this.db.run('DROP TABLE IF EXISTS server_identities');
await this.db.run('DROP TABLE IF EXISTS members');
await this.db.run('DROP TABLE IF EXISTS channels');
await this.db.run('DROP TABLE IF EXISTS resources');
await this.db.run('DROP TABLE IF EXISTS messages');
});
}
// returns the id of the identity inserted
async addIdentity(publicKeyPem: string, privateKeyPem: string): Promise<number> {
let result = await this.db.run(`
INSERT INTO identities (public_key, private_key) VALUES (?, ?)
`, [ publicKeyPem, privateKeyPem ]);
if (!result || result.changes !== 1) {
throw new Error('unable to insert identity');
}
return result.lastID as number;
}
// returns the id (client-side) of the server inserted
async addServer(url: string, cert?: string, name?: string): Promise<number> {
let result = await this.db.run(`
INSERT INTO servers (url, cert, name, icon_resource_id) VALUES (?, ?, ?, NULL)
`, [ url, cert, name ]);
if (!result || result.changes !== 1) {
throw new Error('unable to insert server');
}
return result.lastID as number;
}
async removeServer(serverId: string): Promise<void> {
let result = await this.db.run('DELETE FROM servers WHERE id=?', [ serverId ]);
if (result.changes != 1) {
throw new Error('unable to remove server');
}
}
async addServerIdentity(serverId: number, identityId: number): Promise<void> {
let result = await this.db.run(`
INSERT INTO server_identities (server_id, identity_id) VALUES (?, ?)
`, [ serverId, identityId ]);
if (result.changes != 1) {
throw new Error('unable to insert server identity');
}
}
async updateServer(serverId: string, serverMeta: ServerMetaData): Promise<void> {
let result = await this.db.run('UPDATE servers SET name=?, icon_resource_id=? WHERE id=?', [ serverMeta.name, serverMeta.iconResourceId, serverId ]);
if (result.changes != 1) {
throw new Error('unable to update server');
}
}
async updateServerMemberId(serverId: string, memberId: string): Promise<void> {
let result = await this.db.run('UPDATE servers SET member_id=? WHERE id=?', [ memberId, serverId ]);
if (result.changes != 1) {
throw new Error(`unable to update member id, s#${serverId}, mem#${memberId}`);
}
}
async updateServerMembers(serverId: string, members: Member[]): Promise<void> {
await this.queueTransaction(async () => {
await this.db.run('DELETE FROM members WHERE server_id=?', [ serverId ]);
let stmt = await this.db.prepare('INSERT INTO members (id, server_id, display_name, status, avatar_resource_id, role_name, role_color, role_priority, privileges) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
for (let member of members) {
let result = await stmt.run([ member.id, serverId, member.displayName, member.status, member.avatarResourceId, member.roleName, member.roleColor, member.rolePriority, member.privileges?.join(',') ]);
if (result.changes != 1) {
// note: probably want to warn and continue
throw new Error('failed to insert member');
}
}
await stmt.finalize();
});
}
async clearAllMemberStatus(serverId: string): Promise<void> {
await this.db.run(`UPDATE members SET status='unknown' WHERE server_id=?`, [ serverId ]);
}
async updateServerChannels(serverId: string, channels: Channel[]): Promise<void> {
console.log('setting to ' + channels.length + ' channels');
await this.queueTransaction(async () => {
await this.db.run('DELETE FROM channels WHERE server_id=?', [ serverId ]);
let stmt = await this.db.prepare('INSERT INTO channels (id, server_id, "index", name, flavor_text) VALUES (?, ?, ?, ?, ?)');
for (let channel of channels) {
let result = await stmt.run([ channel.id, serverId, channel.index, channel.name, channel.flavorText ]);
if (result.changes != 1) {
// note: probably want to warn and continue
throw new Error('failed to insert channel');
}
}
await stmt.finalize();
});
}
// TODO: make this singular and a non-transaction based function?
async upsertServerResources(serverId: string, resources: Resource[]): Promise<void> {
await this.queueTransaction(async () => {
let currentSizeResult = await this.db.get('SELECT SUM(data_size) AS current_size FROM resources WHERE server_id=?', [ serverId ]);
let currentSize = parseInt(currentSizeResult.current_size || 0);
let stmt = await this.db.prepare(`
INSERT INTO resources (id, server_id, hash, data, data_size, last_used) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (id, server_id) DO UPDATE SET hash=?3, data=?4, last_used=?6
`);
for (let resource of resources) {
if (resource.data.length > Globals.MAX_CACHED_RESOURCE_SIZE) {
continue;
}
while (resource.data.length + currentSize > Globals.MAX_SERVER_RESOURCE_CACHE_SIZE) {
let targetResult = await this.db.get('SELECT id, data_size FROM resources ORDER BY last_used ASC LIMIT 1');
let deleteResult = await this.db.run('DELETE FROM resources WHERE id=?', [ targetResult.id ]);
if (deleteResult.changes != 1) {
throw new Error('failed to delete excess resource');
}
currentSize -= targetResult.data_size;
}
let result = await stmt.run([ resource.id, serverId, resource.hash, resource.data, resource.data.length, new Date().getTime() ]);
if (result.changes != 1) {
throw new Error('failed to insert resource');
}
}
await stmt.finalize();
});
}
async upsertServerMessages(serverId: string, channelId: string, messages: Message[]): Promise<void> {
await this.queueTransaction(async () => {
let stmt = await this.db.prepare(`
INSERT INTO messages (
id, server_id, channel_id, member_id, sent_dtg, text
, resource_id, resource_name, resource_width, resource_height, resource_preview_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT (id, server_id)
DO UPDATE SET
server_id=?2, channel_id=?3, member_id=?4, sent_dtg=?5, text=?6
, resource_id=?7, resource_name=?8, resource_width=?9, resource_height=?10, resource_preview_id=?11
`);
for (let message of messages) {
let result = await stmt.run([
message.id, serverId, message.channel.id,
message.member.id, message.sent.getTime(), message.text,
message.resourceId, message.resourceName,
message.resourceWidth, message.resourceHeight,
message.resourcePreviewId
]);
if (result.changes != 1) {
// note: probably want to warn and continue
throw new Error('failed to insert message');
}
}
await stmt.finalize();
// delete the oldest messages if the cache is too big
await this.db.run(`
DELETE FROM messages WHERE id IN (
SELECT id FROM messages WHERE server_id=?1 AND channel_id=?2 ORDER BY sent_dtg
LIMIT max(0, (SELECT COUNT(*) FROM messages WHERE server_id=?1 AND channel_id=?2) - ?3)
)
`, [ serverId, channelId, Globals.MAX_CACHED_CHANNEL_MESSAGES ]);
});
}
async clearServerMessages(serverId: string, channelId: string): Promise<void> {
await this.db.run('DELETE FROM messages WHERE server_id=? AND channel_id=?', [ serverId, channelId ]);
}
async deleteServerMessages(serverId: string, messageIds: string[]): Promise<void> {
await this.queueTransaction(async () => {
let stmt = await this.db.prepare('DELETE FROM messages WHERE id=? AND server_id=?'); // include server_id for security purposes
for (let messageId of messageIds) {
let result = await stmt.run([ messageId, serverId ]);
if (result.changes != 1) {
// note: probably want to warn and continue
throw new Error('failed to delete message');
}
}
});
}
async getServerConfigs(): Promise<ServerConfig[]> {
let result = await this.db.all(`
SELECT
servers.id AS server_id
, servers.url AS url
, servers.cert AS server_cert
, servers.member_id AS member_id
, identities.public_key AS public_key
, identities.private_key AS private_key
FROM
server_identities
, servers
, identities
WHERE
server_identities.identity_id = identities.id
AND server_identities.server_id = servers.id
`);
return result.map((dataServerConfig: any) => ServerConfig.fromDBData(dataServerConfig));
}
async getServerConfig(serverId: number, identityId: number): Promise<ServerConfig> {
let result = await this.db.get(`
SELECT
servers.id AS server_id
, servers.url AS url
, servers.cert AS server_cert
, servers.member_id AS member_id
, identities.public_key AS public_key
, identities.private_key AS private_key
FROM
server_identities
, servers
, identities
WHERE
server_identities.identity_id = identities.id
AND server_identities.server_id = servers.id
AND servers.id=?
AND identities.id=?
`, [ serverId, identityId ]);
return ServerConfig.fromDBData(result);
}
/*
CREATE TABLE IF NOT EXISTS servers (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT
, url TEXT NOT NULL
, cert TEXT NOT NULL
, name TEXT
, icon_resource_id TEXT
, member_id TEXT
)
*/
async getServer(serverId: string): Promise<CacheServerData | null> {
let result = await this.db.get(`
SELECT
id, url, cert, name,
icon_resource_id, member_id
FROM servers
WHERE id=?
`, [ serverId ]);
if (result.name == null || result.icon_resource_id == null) {
// server is not set up yet.
return null;
}
return CacheServerData.fromDBData(result);
}
async getServerMemberId(serverId: string): Promise<string> {
let server = await this.db.get('SELECT member_id FROM servers WHERE id=?', [ serverId ]);
return server.member_id;
}
// returns null if no members
async getMembers(serverId: string): Promise<Member[] | null> {
let members = await this.db.all('SELECT * FROM members WHERE server_id=?', [ serverId ]);
if (members.length === 0) {
return null;
}
return members.map((dataMember: any) => Member.fromDBData(dataMember));
}
// returns null if no channels
async getChannels(serverId: string): Promise<Channel[] | null> {
let channels = await this.db.all('SELECT * FROM channels WHERE server_id=?', [ serverId ]);
if (channels.length === 0) {
return null;
}
return channels.map((dataChannel: any) => Channel.fromDBData(dataChannel));
}
// returns [] if no messages found
async getMessagesRecent(
serverId: string, channelId: string, number: number,
members: Map<string, Member>, channels: Map<string, Channel>
): Promise<Message[]> {
let messages = await this.db.all(`
SELECT * FROM (
SELECT
"id", "channel_id", "member_id"
,"sent_dtg", "text"
, "resource_id"
, "resource_name"
, "resource_width"
, "resource_height"
, "resource_preview_id"
FROM "messages"
WHERE "server_id"=? AND "channel_id"=?
ORDER BY "sent_dtg" DESC
LIMIT ?
) AS "r" ORDER BY "r"."sent_dtg" ASC
`, [ serverId, channelId, number ]);
return messages.map((dataMessage: any) => Message.fromDBData(dataMessage, members, channels));
}
// returns null if no messages found
async getMessagesBefore(
serverId: string, channelId: string, messageId: string, number: number,
members: Map<string, Member>, channels: Map<string, Channel>
): Promise<Message[] | null> {
// Note: this query succeeds returning no results if the message with specified id is not found
let messages = await this.db.all(`
SELECT * FROM (
SELECT
"id", "channel_id", "member_id"
, "sent_dtg", "text"
, "resource_id"
, "resource_name"
, "resource_width"
, "resource_height"
, "resource_preview_id"
FROM "messages"
WHERE
"server_id"=?
AND "channel_id"=?
AND "sent_dtg" < (SELECT "sent_dtg" FROM "messages" WHERE "id"=?)
ORDER BY "sent_dtg" DESC
LIMIT ?
) AS "r" ORDER BY "r"."sent_dtg" ASC
`, [ serverId, channelId, messageId, number ]);
if (messages.length == 0) {
return null;
}
return messages.map((messageData: any) => Message.fromDBData(messageData, members, channels));
}
// returns null if no messages found
async getMessagesAfter(
serverId: string, channelId: string, messageId: string, number: number,
members: Map<string, Member>, channels: Map<string, Channel>
): Promise<Message[] | null> {
// Note: this query succeeds returning no results if the message with specified id is not found
let messages = await this.db.all(`
SELECT
"id", "channel_id", "member_id"
, "sent_dtg", "text"
, "resource_id"
, "resource_name"
, "resource_width"
, "resource_height"
, "resource_preview_id"
FROM "messages"
WHERE
"server_id"=?
AND "channel_id"=?
AND "sent_dtg" > (SELECT "sent_dtg" FROM "messages" WHERE "id"=?)
ORDER BY "sent_dtg" ASC
LIMIT ?
`, [ serverId, channelId, messageId, number ]);
if (messages.length == 0) {
return null;
}
return messages.map((messageData: any) => Message.fromDBData(messageData, members, channels));
}
async getResource(serverId: string, resourceId: string): Promise<Resource | null> {
let row = await this.db.get('SELECT id, data, hash FROM resources WHERE server_id=? AND id=?', [ serverId, resourceId ]);
await this.db.run('UPDATE resources SET last_used=?1 WHERE server_id=?2 AND id=?3', [ new Date().getTime(), serverId, resourceId ]);
if (!row) {
return null;
}
return Resource.fromDBData(row);
}
}

View File

@ -1,136 +0,0 @@
import Globals from './globals';
import { Message, ShouldNeverHappenError } from './data-types';
// TODO: this class is junk now :(
// TODO: make this non-static
export default class RecentMessageRAMCache {
private data = new Map<string, { messages: Message[], size: number, hasFirstMessage: boolean, lastUsed: Date }>(); // (guildId, channelId) -> { messages, size, hasFirstMessage, lastUsed }
private size = 0;
clear(): void {
this.data.clear();
this.size = 0;
}
_cullIfNeeded(): void { // TODO: Test this
if (this.size > Globals.MAX_RAM_CACHED_MESSAGES_TOTAL_SIZE) {
let entries = Array.from(this.data.entries())
.map(([ key, value ]) => { return { id: key, value: value }})
.sort((a, b) => b.value.lastUsed.getTime() - a.value.lastUsed.getTime());
while (this.size > Globals.MAX_RAM_CACHED_MESSAGES_TOTAL_SIZE) {
let entry = entries.pop();
if (entry === undefined) throw new ShouldNeverHappenError('No entry in the array but the message cache still has a size...');
this.data.delete(entry.id);
this.size -= entry.value.size;
}
}
}
_cullChannelIfNeeded(guildId: string, channelId: string): void { // TODO: test this
let id = `s#${guildId}/c#${channelId}`;
let value = this.data.get(id);
if (!value) return;
while (value.size > Globals.MAX_RAM_CACHED_MESSAGES_CHANNEL_SIZE) {
value.hasFirstMessage = false;
let message = value.messages.shift();
if (!message) return;
value.size -= message.text?.length ?? 0;
this.size -= message.text?.length ?? 0;
}
}
// @param messages may be modified in addNewMessage due to pass-by-reference fun
putRecentMessages(guildId: string, channelId: string, messages: Message[]): void {
let size = 0;
for (let message of messages) {
size += message.text?.length ?? 0;
}
if (size > Globals.MAX_RAM_CACHED_MESSAGES_CHANNEL_SIZE) return;
let id = `s#${guildId}/c#${channelId}`;
this.data.set(id, {
messages: messages,
size: size,
hasFirstMessage: true, // will be false if this was ever channel-culled
lastUsed: new Date()
});
this.size += size;
this._cullIfNeeded();
}
addNewMessage(guildId: string, message: Message): void {
let channelId = message.channel.id;
let id = `s#${guildId}/c#${channelId}`;
let value = this.data.get(id);
if (!value) return;
value.messages.push(message);
value.size += message.text?.length ?? 0;
this.size += message.text?.length ?? 0;
this._cullChannelIfNeeded(guildId, channelId);
this._cullIfNeeded();
}
updateMessage(guildId: string, oldMessage: Message, newMessage: Message): void {
let channelId = oldMessage.channel.id;
let id = `s#${guildId}/c#${channelId}`;
let value = this.data.get(id);
if (!value) return;
let taggedIndex = value.messages.findIndex(cachedMessage => cachedMessage.id == oldMessage.id);
let [ oldCachedMessage ] = value.messages.splice(taggedIndex, 1, newMessage);
value.size -= oldCachedMessage.text?.length ?? 0;
value.size += newMessage.text?.length ?? 0;
this.size -= oldCachedMessage.text?.length ?? 0;
this.size += newMessage.text?.length ?? 0;
this._cullChannelIfNeeded(guildId, channelId);
this._cullIfNeeded();
}
deleteMessage(guildId: string, message: Message): void {
let channelId = message.channel.id;
let id = `s#${guildId}/c#${channelId}`;
let value = this.data.get(id);
if (!value) return;
let taggedIndex = value.messages.findIndex(cachedMessage => cachedMessage.id == message.id);
let [ oldCachedMessage ] = value.messages.splice(taggedIndex, 1);
value.size -= oldCachedMessage.text?.length ?? 0;
this.size -= oldCachedMessage.text?.length ?? 0;
// No need to cull since we are always shrinking
}
dropChannel(guildId: string, channelId: string): void {
let id = `s#${guildId}/c#${channelId}`;
let value = this.data.get(id);
if (!value) return;
this.size -= value.size;
this.data.delete(id);
}
getRecentMessages(guildId: string, channelId: string, number: number): Message[] | null {
let id = `s#${guildId}/c#${channelId}`;
if (!this.data.has(id)) {
//LOG.silly(`recent message cache miss on ${id} requesting ${number}`);
return null; // not in the cache
}
let value = this.data.get(id);
if (!value) throw new ShouldNeverHappenError('javascript is not on a single thread >:|');
if (!value.hasFirstMessage && value.messages.length < number) {
//LOG.silly(`recent message cache incomplete on ${id} requesting ${number}`, { value });
return null; // requesting older messages than we have
}
value.lastUsed = new Date();
return value.messages.slice(-number);
}
}

View File

@ -33,7 +33,7 @@ export default class Actions {
errorIndicatorAddFunc: async (errorIndicatorElement) => {
await ui.setMembersErrorIndicator(guild, errorIndicatorElement);
},
errorContainer: q.$('#server-members'),
errorContainer: q.$('#guild-members'),
errorMessage: 'Error loading members'
});
}

View File

@ -373,72 +373,6 @@ export interface Changes<T> {
deleted: T[]
}
// Old Garbage
export class ServerConfig {
private constructor(
public readonly guildId: string,
public readonly url: string,
public readonly serverCert: string,
public readonly memberId: string,
public readonly publicKey: string,
public readonly privateKey: string,
public readonly source?: any
) {}
public static fromDBData(dataServerConfig: any): ServerConfig {
return new ServerConfig(
dataServerConfig.server_id,
dataServerConfig.url,
dataServerConfig.server_cert,
dataServerConfig.member_id,
dataServerConfig.public_key,
dataServerConfig.private_key,
dataServerConfig
);
}
}
export class ServerMetaData {
private constructor(
public readonly name: string,
public readonly iconResourceId: string,
public readonly source?: any
) {}
public static fromServerDBData(data: any): ServerMetaData {
return new ServerMetaData(
data.name,
data.icon_resource_id,
data
);
}
}
export class CacheServerData {
constructor(
public readonly id: string,
public readonly url: string,
public readonly cert: string,
public readonly name: string,
public readonly iconResourceId: string | null,
public readonly memberId: string,
public readonly source?: any
) {}
public static fromDBData(data: any): CacheServerData {
return new CacheServerData(
data.id,
data.url,
data.cert,
data.name,
data.icon_resource_id,
data.member_id,
data
);
}
}
export interface ConnectionInfo {
id: string | null;
avatarResourceId: string | null;

View File

@ -8,7 +8,7 @@ import Q from '../q-module';
import CombinedGuild from '../guild-combined';
export default function createChannel(document: Document, q: Q, ui: UI, guild: CombinedGuild, channel: Channel) {
let element = q.create({ class: 'channel text', 'meta-id': channel.id, 'meta-server-id': guild.id, content: [
let element = q.create({ class: 'channel text', 'meta-id': channel.id, 'meta-guild-id': guild.id, content: [
// Scraped directly from discord (#)
{ class: 'icon', content: BaseElements.TEXT_CHANNEL_ICON },
{ class: 'name', content: channel.name },

View File

@ -10,23 +10,23 @@ import UI from '../ui';
import GuildsManager from '../guilds-manager';
import CombinedGuild from '../guild-combined';
export default function createServerContextMenu(document: Document, q: Q, ui: UI, guildsManager: GuildsManager, guild: CombinedGuild) {
export default function createGuildContextMenu(document: Document, q: Q, ui: UI, guildsManager: GuildsManager, guild: CombinedGuild) {
let element = BaseElements.createContextMenu(document, {
class: 'server-context', content: [
{ class: 'item red leave-server', content: 'Leave Server' }
class: 'guild-context', content: [
{ class: 'item red leave-guild', content: 'Leave Guild' }
]
});
q.$$$(element, '.leave-server').addEventListener('click', async () => {
q.$$$(element, '.leave-guild').addEventListener('click', async () => {
element.removeSelf();
guild.disconnect();
await guildsManager.removeServer(guild);
await guildsManager.removeGuild(guild);
await ui.removeGuild(guild);
let firstServerElement = q.$_('#server-list .server');
if (firstServerElement) {
firstServerElement.click();
let firstGuildElement = q.$_('#guild-list .guild');
if (firstGuildElement) {
firstGuildElement.click();
} else {
LOG.warn('no first server element to click on');
LOG.warn('no first guild element to click on');
}
});

View File

@ -28,7 +28,7 @@ export default function createImageContextMenu(
q.$$$(contextMenu, '.copy-image').innerText = 'Copying...';
let nativeImage: electron.NativeImage;
if (mime != 'image/png' && mime != 'image/jpeg' && mime != 'image/jpg') {
// use sharp to convserver: serverrt to png since nativeImage only supports jpeg/png
// use sharp to convert to png since nativeImage only supports jpeg/png
nativeImage = electron.nativeImage.createFromBuffer(await sharp(buffer).png().toBuffer());
} else {
nativeImage = electron.nativeImage.createFromBuffer(buffer);

View File

@ -1,10 +1,10 @@
import Q from '../q-module';
import UI from '../ui';
import createServerTitleContextMenu from './context-menu-guild-title';
import createGuildTitleContextMenu from './context-menu-guild-title';
import ElementsUtil from './require/elements-util';
export default function bindAddServerTitleEvents(document: Document, q: Q, ui: UI) {
q.$('#server-name-container').addEventListener('click', () => {
export default function bindAddGuildTitleEvents(document: Document, q: Q, ui: UI) {
q.$('#guild-name-container').addEventListener('click', () => {
if (ui.activeConnection === null) return;
if (ui.activeGuild === null) return;
if (!ui.activeGuild.isSocketVerified()) return;
@ -13,8 +13,8 @@ export default function bindAddServerTitleEvents(document: Document, q: Q, ui: U
!ui.activeConnection.privileges.includes('modify_members')
) return;
let contextMenu = createServerTitleContextMenu(document, q, ui, ui.activeGuild);
let contextMenu = createGuildTitleContextMenu(document, q, ui, ui.activeGuild);
document.body.appendChild(contextMenu);
ElementsUtil.alignContextElement(contextMenu, q.$('#server-name-container'), { top: 'bottom', centerX: 'centerX' });
ElementsUtil.alignContextElement(contextMenu, q.$('#guild-name-container'), { top: 'bottom', centerX: 'centerX' });
});
}

View File

@ -27,10 +27,10 @@ export default function bindTextInputEvents(document: Document, q: Q, ui: UI): v
sendingMessage = true;
let server = ui.activeGuild as CombinedGuild;
let guild = ui.activeGuild as CombinedGuild;
let channel = ui.activeChannel as Channel;
if (!server.isSocketVerified()) {
if (!guild.isSocketVerified()) {
LOG.warn('client attempted to send message while not verified');
q.$('#send-error').innerText = 'Not Connected to Server';
await ElementsUtil.shakeElement(q.$('#send-error'), 400);
@ -46,11 +46,11 @@ export default function bindTextInputEvents(document: Document, q: Q, ui: UI): v
return;
}
await ui.lockMessages(server, channel, async () => {
await ui.lockMessages(guild, channel, async () => {
q.$('#text-input').removeAttribute('contenteditable');
q.$('#text-input').classList.add('sending');
try {
await server.requestSendMessage(channel.id, text);
await guild.requestSendMessage(channel.id, text);
q.$('#send-error').innerText = '';
q.$('#text-input').innerText = '';

View File

@ -98,7 +98,7 @@ export default function createGuildSettingsOverlay(document: Document, q: Q, gui
// Set Name
if (newName != guildMeta.name) {
try {
await guild.requestSetServerName(newName);
await guild.requestSetGuildName(newName);
guildMeta = await guild.fetchMetadata();
} catch (e) {
LOG.error('error setting new guild name', e);
@ -112,7 +112,7 @@ export default function createGuildSettingsOverlay(document: Document, q: Q, gui
// Set Icon
if (!failed && newIconBuff != null) {
try {
await guild.requestSetServerIcon(newIconBuff);
await guild.requestSetGuildIcon(newIconBuff);
newIconBuff = null; // prevent resubmit
} catch (e) {
LOG.error('error setting new guild icon', e);

View File

@ -21,7 +21,7 @@ export default class Globals {
static MAX_CACHED_CHANNEL_MESSAGES = 1000; // the 1000 most recent messages in each text channel are cached (in the sqlite db)
static MAX_SERVER_RESOURCE_CACHE_SIZE = 1024 * 1024 * 1024; // 1 GB max resource cache per guild
static MAX_GUILD_RESOURCE_CACHE_SIZE = 1024 * 1024 * 1024; // 1 GB max resource cache per guild
static MAX_CACHED_RESOURCE_SIZE = 1024 * 1024 * 4; // 4 MB is the biggest resource that will be cached
static MAX_RAM_CACHED_MESSAGES_CHANNEL_CHARACTERS = 1024 * 1024 * 64; // at most, 64 MB of channel

View File

@ -7,7 +7,7 @@ import * as socketio from 'socket.io-client';
import PersonalDBGuild from './guild-personal-db';
import RAMGuild from './guild-ram';
import SocketGuild from './guild-socket';
import { Changes, Channel, ConnectionInfo, GuildMetadata, GuildMetadataWithIds, Member, Message, Resource, ServerMetaData, SocketConfig, Token } from './data-types';
import { Changes, Channel, ConnectionInfo, GuildMetadata, GuildMetadataWithIds, Member, Message, Resource, SocketConfig, Token } from './data-types';
import MessageRAMCache from "./message-ram-cache";
import PersonalDB from "./personal-db";
@ -299,11 +299,11 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
await this.socketGuild.requestSetAvatar(avatar);
}
// TODO: Rename Server -> Guild
async requestSetServerName(serverName: string): Promise<void> {
await this.socketGuild.requestSetServerName(serverName);
async requestSetGuildName(guildName: string): Promise<void> {
await this.socketGuild.requestSetGuildName(guildName);
}
async requestSetServerIcon(serverIcon: Buffer): Promise<void> {
await this.socketGuild.requestSetServerIcon(serverIcon);
async requestSetGuildIcon(guildIcon: Buffer): Promise<void> {
await this.socketGuild.requestSetGuildIcon(guildIcon);
}
async requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise<void> {
await this.socketGuild.requestDoUpdateChannel(channelId, name, flavorText);

View File

@ -4,7 +4,7 @@ import Logger from '../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import * as socketio from "socket.io-client";
import { Channel, GuildMetadata, Member, Message, Resource, ServerMetaData, Token } from "./data-types";
import { Channel, GuildMetadata, Member, Message, Resource, Token } from "./data-types";
import Globals from './globals';
import { Connectable, AsyncRequestable, AsyncGuaranteedFetchable } from './guild-types';
import DedupAwaiter from './dedup-awaiter';
@ -145,11 +145,11 @@ export default class SocketGuild extends EventEmitter<Connectable> implements As
async requestSetAvatar(avatar: Buffer): Promise<void> {
await this.query(Globals.DEFAULT_SOCKET_TIMEOUT, 'set-avatar', avatar);
}
async requestSetServerName(serverName: string): Promise<void> {
await this.query(Globals.DEFAULT_SOCKET_TIMEOUT, 'set-name', serverName);
async requestSetGuildName(guildName: string): Promise<void> {
await this.query(Globals.DEFAULT_SOCKET_TIMEOUT, 'set-name', guildName);
}
async requestSetServerIcon(serverIcon: Buffer): Promise<void> {
await this.query(Globals.DEFAULT_SOCKET_TIMEOUT, 'set-icon', serverIcon);
async requestSetGuildIcon(guildIcon: Buffer): Promise<void> {
await this.query(Globals.DEFAULT_SOCKET_TIMEOUT, 'set-icon', guildIcon);
}
async requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise<void> {
let _changedChannel = await this.query(Globals.DEFAULT_SOCKET_TIMEOUT, 'update-channel', channelId, name, flavorText);

View File

@ -45,8 +45,8 @@ export interface AsyncRequestable {
requestSetStatus(status: string): Promise<void>;
requestSetDisplayName(displayName: string): Promise<void>;
requestSetAvatar(avatar: Buffer): Promise<void>;
requestSetServerName(serverName: string): Promise<void>;
requestSetServerIcon(serverIcon: Buffer): Promise<void>;
requestSetGuildName(guildName: string): Promise<void>;
requestSetGuildIcon(guildIcon: Buffer): Promise<void>;
requestDoUpdateChannel(channelId: string, name: string, flavorText: string | null): Promise<void>;
requestDoCreateChannel(name: string, flavorText: string | null): Promise<void>;
requestDoRevokeToken(token: string): Promise<void>;

View File

@ -9,8 +9,8 @@ import * as socketio from 'socket.io-client';
import * as crypto from 'crypto';
import { Changes, Channel, GuildMetadata, GuildMetadataWithIds, Member, Message, Resource, ServerConfig, SocketConfig, Token } from './data-types';
import { IAddServerData } from './elements/overlay-add-server';
import { Changes, Channel, GuildMetadata, GuildMetadataWithIds, Member, Message, Resource, SocketConfig, Token } from './data-types';
import { IAddGuildData } from './elements/overlay-add-guild';
import { EventEmitter } from 'tsee';
import CombinedGuild from './guild-combined';
import PersonalDB from './personal-db';
@ -54,7 +54,7 @@ export default class GuildsManager extends EventEmitter<{
}
async _connectFromConfig(guildMetadata: GuildMetadataWithIds, socketConfig: SocketConfig): Promise<CombinedGuild> {
LOG.debug(`connecting to server#${guildMetadata.id} at ${socketConfig.url}`);
LOG.debug(`connecting to g#${guildMetadata.id} at ${socketConfig.url}`);
let guild = await CombinedGuild.create(
guildMetadata,
@ -112,8 +112,8 @@ export default class GuildsManager extends EventEmitter<{
});
}
async addNewGuild(serverConfig: IAddServerData, displayName: string, avatarBuff: Buffer): Promise<CombinedGuild> {
const { name, url, cert, token } = serverConfig;
async addNewGuild(guildConfig: IAddGuildData, displayName: string, avatarBuff: Buffer): Promise<CombinedGuild> {
const { name, url, cert, token } = guildConfig;
LOG.debug('Adding new server', { name, url, cert, token, displayName, avatarBuff });
@ -132,7 +132,7 @@ export default class GuildsManager extends EventEmitter<{
});
});
try {
// Create a new Public/Private key pair to identify ourselves with this server
// Create a new Public/Private key pair to identify ourselves with this guild
let { publicKey, privateKey } = await new Promise((resolve, reject) => {
crypto.generateKeyPair('rsa', { modulusLength: 4096 }, (err, publicKey, privateKey) => {
if (err) {
@ -178,7 +178,7 @@ export default class GuildsManager extends EventEmitter<{
}
}
async removeServer(guild: CombinedGuild): Promise<void> {
async removeGuild(guild: CombinedGuild): Promise<void> {
await this.personalDB.queueTransaction(async () => {
await this.personalDB.removeGuildSockets(guild.id);
await this.personalDB.removeGuild(guild.id);

View File

@ -35,16 +35,16 @@
</div>
</div>
<div id="content">
<div id="server-list-container">
<div id="server-list"></div>
<div id="add-server">
<div id="guild-list-container">
<div id="guild-list"></div>
<div id="add-guild">
<div class="pill"></div>
<img src="./img/add-server-icon.png">
<img src="./img/add-guild-icon.png">
</div>
</div>
<div id="server">
<div id="server-sidebar">
<div id="server-name-container"><span id="server-name"></span></div>
<div id="guild">
<div id="guild-sidebar">
<div id="guild-name-container"><span id="guild-name"></span></div>
<div id="channel-list"></div>
<div id="connection" class="hidden member">
<div class="icon">
@ -87,7 +87,7 @@
</div>
</div>
</div>
<div id="server-members"></div>
<div id="guild-members"></div>
</div>
</div>
</div>

View File

@ -13,11 +13,11 @@ export default class ResourceRAMCache {
let id = `s#${guildId}/r#${resource.id}`;
this.data.set(id, { resource: resource, lastUsed: new Date() });
this.size += resource.data.length;
if (this.size > Globals.MAX_SERVER_RESOURCE_CACHE_SIZE) { // TODO: this feature needs to be tested
if (this.size > Globals.MAX_GUILD_RESOURCE_CACHE_SIZE) { // TODO: this feature needs to be tested
let entries = Array.from(this.data.entries())
.map(([ key, value ]) => { return { id: key, value: value }; })
.sort((a, b) => b.value.lastUsed.getTime() - a.value.lastUsed.getTime()); // oldest last (for pop)
while (this.size > Globals.MAX_SERVER_RESOURCE_CACHE_SIZE) {
while (this.size > Globals.MAX_GUILD_RESOURCE_CACHE_SIZE) {
let entry = entries.pop();
if (entry === undefined) throw new ShouldNeverHappenError('No entry in the array but the ram cache still has a size...');
this.data.delete(entry.id);

View File

@ -50,7 +50,7 @@
}
}
#server.privilege-modify_channels .channel:hover .modify,
#server.privilege-modify_channels .channel.active .modify {
#guild.privilege-modify_channels .channel:hover .modify,
#guild.privilege-modify_channels .channel.active .modify {
display: unset;
}

View File

@ -47,8 +47,8 @@
margin-right: 8px;
}
.server-title-context .item .icon img,
.server-title-context .item .icon svg {
.guild-title-context .item .icon img,
.guild-title-context .item .icon svg {
width: 16px;
height: 16px;
}
@ -105,7 +105,7 @@
border-radius: 4px;
}
.content.server {
.content.guild {
line-height: 1;
.name:not(:last-child) {

View File

@ -201,9 +201,9 @@ body > .overlay {
}
}
/* Server Settings Overlay */
/* guild Settings Overlay */
.content.server-settings {
.content.guild-settings {
min-width: 350px;
.preview {
@ -228,9 +228,9 @@ body > .overlay {
}
}
/* Add Server Overlay */
/* Add guild Overlay */
.content.add-server {
.content.add-guild {
min-width: 350px;
background-color: $background-secondary;
border-radius: 12px;

View File

@ -1,6 +1,6 @@
@import "theme.scss";
#server-list-container {
#guild-list-container {
display: flex;
flex-flow: column;
overflow-y: scroll;
@ -12,25 +12,25 @@
overflow-y: scroll;
}
#server-list {
#guild-list {
display: flex;
flex-flow: column;
}
#server-list::-webkit-scrollbar {
#guild-list::-webkit-scrollbar {
display: none;
}
#add-server,
#server-list .server {
#add-guild,
#guild-list .guild {
cursor: pointer;
margin-bottom: 8px;
display: flex;
align-items: center;
}
#add-server .pill,
#server-list .server .pill {
#add-guild .pill,
#guild-list .guild .pill {
background-color: $header-primary;
width: 8px;
height: 0;
@ -40,29 +40,29 @@
transition: height .1s ease-in-out;
}
#server-list .server.active .pill {
#guild-list .guild.active .pill {
height: 40px;
}
#server-list .server.unread:not(.active) .pill {
#guild-list .guild.unread:not(.active) .pill {
height: 8px;
}
#add-server:hover .pill,
#server-list .server:not(.active):hover .pill {
#add-guild:hover .pill,
#guild-list .guild:not(.active):hover .pill {
height: 20px;
}
#add-server img,
#server-list .server img {
#add-guild img,
#guild-list .guild img {
width: 48px;
height: 48px;
border-radius: 24px;
transition: border-radius .1s ease-in-out;
}
#add-server:hover img,
#server-list .server:hover img,
#server-list .server.active img {
#add-guild:hover img,
#guild-list .guild:hover img,
#guild-list .guild.active img {
border-radius: 16px;
}

View File

@ -1,6 +1,6 @@
@import "theme.scss";
#server-members {
#guild-members {
box-sizing: border-box;
flex: none; /* >:| NOT GONNA SHINK BOI */
background-color: $background-secondary;
@ -10,22 +10,22 @@
padding: 8px 0 8px 8px;
}
#server-members .member {
#guild-members .member {
background-color: $background-secondary;
padding: 4px 8px;
margin-bottom: 4px;
border-radius: 4px;
}
#server-members .member .name {
#guild-members .member .name {
width: calc(208px - 40px);
}
#server-members .member .status-circle {
#guild-members .member .status-circle {
border-color: $background-secondary;
}
#server-members .member:hover {
#guild-members .member:hover {
background-color: $background-modifier-hover;
cursor: pointer;
}

View File

@ -1,11 +1,11 @@
@import "theme.scss";
#server {
#guild {
flex: 1;
display: flex;
}
#server-sidebar {
#guild-sidebar {
flex: none; /* >:| NOT GONNA SHINK BOI */
width: 240px;
display: flex;
@ -14,7 +14,7 @@
border-top-left-radius: 8px;
}
#server-name-container {
#guild-name-container {
padding: 0 16px;
height: 48px;
font-weight: 600;
@ -25,14 +25,14 @@
border-bottom: 1px solid $background-secondary-alt;
}
#server-name {
#guild-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#server.privilege-modify_profile #server-name,
#server.privilege-modify_members #server-name {
#guild.privilege-modify_profile #guild-name,
#guild.privilege-modify_members #guild-name {
cursor: pointer;
}

View File

@ -15,9 +15,9 @@
@import "overlays.scss";
@import "scrollbars.scss";
@import "scrollbars.scss";
@import "server-list.scss";
@import "server-members.scss";
@import "server.scss";
@import "guild-list.scss";
@import "guild-members.scss";
@import "guild.scss";
@import "shake.scss";
@import "status-circles.scss";
@import "title-bar.scss";