225 lines
11 KiB
TypeScript
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[] : [];
|
|
}
|
|
} |