fchat-rising/webchat/logs.ts

225 lines
11 KiB
TypeScript

import {EventMessage, Message, Settings as SettingsImpl} from '../chat/common';
import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
import l from '../chat/localize';
type StoredConversation = {id: number, key: string, name: string};
type StoredMessage = {
id: number, conversation: number, type: Conversation.Message.Type, sender: string, text: string, time: Date, day: number | string
};
async function promisifyRequest<T>(req: IDBRequest): Promise<T> {
return new Promise<T>((resolve, reject) => {
req.onsuccess = () => resolve(<T>req.result);
req.onerror = () => reject(req.error);
});
}
async function iterate<S, T>(request: IDBRequest, map: (stored: S) => T, count: number = -1): Promise<T[]> {
const array: T[] = [];
return new Promise<T[]>((resolve, reject) => {
request.onsuccess = function(): void {
const c = <IDBCursorWithValue | undefined>this.result;
if(!c || count !== -1 && array.length >= count) return resolve(array); //tslint:disable-line:strict-boolean-expressions
array.push(map(<S>c.value));
c.continue();
};
request.onerror = () => reject(request.error);
});
}
const dayMs = 86400000;
const charactersKey = 'fchat.characters';
let hasComposite = true;
let getComposite: (conv: number, day: number) => string | number[] = (conv, day) => [conv, day];
const decode = (str: string) => (str.charCodeAt(0) << 16) + str.charCodeAt(1);
try {
IDBKeyRange.only([]);
} catch {
hasComposite = false;
const encode = (num: number) => String.fromCharCode((num >> 16) % 65536) + String.fromCharCode(num % 65536);
getComposite = (conv, day) => `${encode(conv)}${encode(day)}`;
}
type Index = {[key: string]: StoredConversation | undefined};
async function openDatabase(character: string): Promise<IDBDatabase> {
const request = window.indexedDB.open(`logs-${character}`);
request.onupgradeneeded = () => {
const db = request.result;
const logsStore = db.createObjectStore('logs', {keyPath: 'id', autoIncrement: true});
logsStore.createIndex('conversation', 'conversation');
logsStore.createIndex('conversation-day', hasComposite ? ['conversation', 'day'] : 'day');
db.createObjectStore('conversations', {keyPath: 'id', autoIncrement: true});
};
return promisifyRequest<IDBDatabase>(request);
}
async function getIndex(db: IDBDatabase): Promise<Index> {
try {
const trans = db.transaction(['conversations']);
const index: Index = {};
await iterate(trans.objectStore('conversations').openCursor(), (x: StoredConversation) => index[x.key] = x);
return index;
} catch {
alert(l('logs.corruption.web'));
return {};
}
}
export class Logs implements Logging {
canZip = true;
index?: Index;
loadedDb?: IDBDatabase;
loadedCharacter?: string;
loadedIndex?: Index;
db?: IDBDatabase;
constructor() {
core.connection.onEvent('connecting', async() => {
const characters = (await this.getAvailableCharacters());
if(characters.indexOf(core.connection.character) === -1)
window.localStorage.setItem(charactersKey, JSON.stringify(characters.concat(core.connection.character)));
try {
this.db = await openDatabase(core.connection.character);
this.index = await getIndex(this.db);
} catch(e) {
console.error(e);
}
});
}
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
if(this.db === undefined) return;
let conv = this.index![conversation.key];
if(conv === undefined) {
const cTrans = this.db.transaction(['conversations'], 'readwrite');
const convId = await promisifyRequest<number>(cTrans.objectStore('conversations').add(
{key: conversation.key, name: conversation.name}));
this.index![conversation.key] = conv = {id: convId, key: conversation.key, name: conversation.name};
}
const lTrans = this.db.transaction(['logs'], 'readwrite');
const sender = message.type === Conversation.Message.Type.Event ? undefined : message.sender.name;
const day = Math.floor(message.time.getTime() / dayMs - message.time.getTimezoneOffset() / 1440);
const dayValue = hasComposite ? day : getComposite(conv.id, day);
await promisifyRequest<number>(lTrans.objectStore('logs').put(
{conversation: conv.id, type: message.type, sender, text: message.text, time: message.time, day: dayValue}));
}
async getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
if(this.db === undefined) return [];
const trans = this.db.transaction(['logs']);
const conv = this.index![conversation.key];
if(conv === undefined) return [];
return (await iterate(trans.objectStore('logs').index('conversation').openCursor(conv.id, 'prev'),
(value: StoredMessage) => value.type === Conversation.Message.Type.Event ? new EventMessage(value.text, value.time) :
new Message(value.type, core.characters.get(value.sender), value.text, value.time), 20)).reverse();
}
private async loadIndex(character: string): Promise<Index> {
if(character === this.loadedCharacter) return this.loadedIndex!;
if(character === core.connection.character) {
this.loadedDb = this.db;
this.loadedIndex = this.index;
} else
try {
this.loadedDb = await openDatabase(character);
this.loadedIndex = await getIndex(this.loadedDb);
} catch(e) {
console.error(e);
return {};
}
this.loadedCharacter = character;
return this.loadedIndex!;
}
async getConversations(character: string): Promise<ReadonlyArray<{key: string, name: string}>> {
const index = await this.loadIndex(character);
return Object.keys(index).map((k) => index[k]!);
}
async getLogs(character: string, key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
await this.loadIndex(character);
if(this.loadedDb === undefined) return [];
const id = this.loadedIndex![key]!.id;
const trans = this.loadedDb.transaction(['logs']);
const day = Math.floor(date.getTime() / dayMs - date.getTimezoneOffset() / 1440);
return iterate(trans.objectStore('logs').index('conversation-day').openCursor(getComposite(id, day)),
(value: StoredMessage) => value.type === Conversation.Message.Type.Event ? new EventMessage(value.text, value.time) :
new Message(value.type, core.characters.get(value.sender), value.text, value.time));
}
async getLogDates(character: string, key: string): Promise<ReadonlyArray<Date>> {
await this.loadIndex(character);
if(this.loadedDb === undefined) return [];
const id = this.loadedIndex![key]!.id;
const trans = this.loadedDb.transaction(['logs']);
const bound = IDBKeyRange.bound(getComposite(id, 0), getComposite(id, 1000000));
return iterate(trans.objectStore('logs').index('conversation-day').openCursor(bound, 'nextunique'), (value: StoredMessage) => {
const date = new Date((hasComposite ? <number>value.day : decode((<string>value.day).substr(2))) * dayMs);
return new Date(date.getTime() + date.getTimezoneOffset() * 60000);
});
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const stored = window.localStorage.getItem(charactersKey);
return stored !== null ? JSON.parse(stored) as string[] : [];
}
}
export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K, character: string = core.connection.character): Promise<Settings.Keys[K] | undefined> {
const stored = window.localStorage.getItem(`${character}.settings.${key}`);
if(stored === null) {
if(key === 'pinned') {
const tabs20 = window.localStorage.getItem(`tabs_${character.toLowerCase().replace(' ', '_')}`);
if(tabs20 !== null)
try {
const tabs = JSON.parse(tabs20) as {type: string, id: string, title: string}[];
const pinned: Settings.Keys['pinned'] = {channels: [], private: []};
pinned.channels = tabs.filter((x) => x.type === 'channel').map((x) => x.id.toLowerCase());
pinned.private = tabs.filter((x) => x.type === 'user').map((x) => x.title);
return pinned as Settings.Keys[K];
} catch {
return undefined;
}
} else if(key === 'settings') {
const settings20 = window.localStorage.getItem(`chat_settings`);
if(settings20 !== null)
try {
const old = JSON.parse(settings20) as {
animatedIcons: boolean, autoIdle: boolean, autoIdleTime: number, delimit: boolean, disableIconTag: boolean,
enableLogging: boolean, highlightMentions: boolean, highlightWords: string[],
html5Audio: boolean, joinLeaveAlerts: boolean, leftClickOpensFlist: boolean
};
const settings = new SettingsImpl();
settings.animatedEicons = old.animatedIcons;
if(old.autoIdle) settings.idleTimer = old.autoIdleTime / 60000;
settings.messageSeparators = old.delimit;
if(old.disableIconTag) settings.disallowedTags.push('icon');
settings.logMessages = old.enableLogging;
settings.highlight = old.highlightMentions;
settings.highlightWords = old.highlightWords;
settings.playSound = old.html5Audio;
settings.joinMessages = old.joinLeaveAlerts;
settings.clickOpensMessage = !old.leftClickOpensFlist;
return settings as unknown as Settings.Keys[K];
} catch {
return undefined;
}
}
return undefined;
}
return JSON.parse(stored) as Settings.Keys[K];
}
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
window.localStorage.setItem(`${core.connection.character}.settings.${key}`, JSON.stringify(value));
return Promise.resolve();
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const stored = window.localStorage.getItem(charactersKey);
return stored !== null ? JSON.parse(stored) as string[] : [];
}
}