262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import {getByteLength, Message as MessageImpl} from '../chat/common';
|
|
import core from '../chat/core';
|
|
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
|
|
|
declare global {
|
|
class TextEncoder {
|
|
readonly encoding: string;
|
|
|
|
encode(input?: string, options?: {stream: boolean}): Uint8Array;
|
|
}
|
|
|
|
class TextDecoder {
|
|
readonly encoding: string;
|
|
readonly fatal: boolean;
|
|
readonly ignoreBOM: boolean;
|
|
|
|
constructor(utfLabel?: string, options?: {fatal?: boolean, ignoreBOM?: boolean})
|
|
|
|
decode(input?: ArrayBufferView, options?: {stream: boolean}): string;
|
|
}
|
|
}
|
|
|
|
const dayMs = 86400000;
|
|
let fs: FileSystem;
|
|
|
|
export class GeneralSettings {
|
|
account = '';
|
|
password = '';
|
|
host = 'wss://chat.f-list.net:9799';
|
|
theme = 'dark';
|
|
}
|
|
|
|
type Index = {[key: string]: {name: string, index: {[key: number]: number | undefined}} | undefined};
|
|
|
|
/*tslint:disable:promise-function-async*///all of these are simple wrappers
|
|
export function init(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
document.addEventListener('deviceready', () => {
|
|
window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, (f) => {
|
|
fs = f;
|
|
resolve();
|
|
}, reject);
|
|
});
|
|
});
|
|
}
|
|
|
|
function readAsString(file: Blob): Promise<string> {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(<string>reader.result);
|
|
reader.onerror = reject;
|
|
reader.readAsText(file);
|
|
});
|
|
}
|
|
|
|
function readAsArrayBuffer(file: Blob): Promise<ArrayBuffer> {
|
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(<ArrayBuffer>reader.result);
|
|
reader.onerror = reject;
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
}
|
|
|
|
function getFile(root: DirectoryEntry, path: string): Promise<File | undefined> {
|
|
return new Promise<File | undefined>((resolve, reject) => {
|
|
root.getFile(path, {create: false}, (entry) => entry.file((file) => {
|
|
resolve(file);
|
|
}, reject), (e) => {
|
|
if(e.code === FileError.NOT_FOUND_ERR) resolve(undefined);
|
|
else reject(e);
|
|
});
|
|
});
|
|
}
|
|
|
|
function getWriter(root: DirectoryEntry, path: string): Promise<FileWriter> {
|
|
return new Promise<FileWriter>((resolve, reject) => root.getFile(path, {create: true},
|
|
(file) => file.createWriter(resolve, reject), reject));
|
|
}
|
|
|
|
function getDir(root: DirectoryEntry, name: string): Promise<DirectoryEntry> {
|
|
return new Promise<DirectoryEntry>((resolve, reject) => root.getDirectory(name, {create: true}, resolve, reject));
|
|
}
|
|
|
|
function getEntries(root: DirectoryEntry): Promise<ReadonlyArray<Entry>> {
|
|
const reader = root.createReader();
|
|
return new Promise<ReadonlyArray<Entry>>((resolve, reject) => reader.readEntries(resolve, reject));
|
|
}
|
|
|
|
//tslib:enable
|
|
|
|
function serializeMessage(message: Conversation.Message): Blob {
|
|
const name = message.type !== Conversation.Message.Type.Event ? message.sender.name : '';
|
|
const buffer = new ArrayBuffer(8);
|
|
const dv = new DataView(buffer);
|
|
dv.setUint32(0, message.time.getTime() / 1000);
|
|
dv.setUint8(4, message.type);
|
|
const senderLength = getByteLength(name);
|
|
dv.setUint8(5, senderLength);
|
|
const textLength = getByteLength(message.text);
|
|
dv.setUint16(6, textLength);
|
|
return new Blob([buffer, name, message.text, String.fromCharCode(senderLength + textLength + 10)]);
|
|
}
|
|
|
|
function deserializeMessage(buffer: ArrayBuffer): {message: Conversation.Message, end: number} {
|
|
const dv = new DataView(buffer, 0, 8);
|
|
const time = dv.getUint32(0);
|
|
const type = dv.getUint8(4);
|
|
const senderLength = dv.getUint8(5);
|
|
const messageLength = dv.getUint16(6);
|
|
let index = 8;
|
|
const sender = decoder.decode(new DataView(buffer, index, senderLength));
|
|
index += senderLength;
|
|
const text = decoder.decode(new DataView(buffer, index, messageLength));
|
|
return {message: new MessageImpl(type, core.characters.get(sender), text, new Date(time)), end: index + messageLength + 2};
|
|
}
|
|
|
|
const decoder = new TextDecoder('utf8');
|
|
|
|
export class Logs implements Logging.Persistent {
|
|
private index: Index = {};
|
|
private logDir: DirectoryEntry;
|
|
|
|
constructor() {
|
|
core.connection.onEvent('connecting', async() => {
|
|
this.index = {};
|
|
const charDir = await getDir(fs.root, core.connection.character);
|
|
this.logDir = await getDir(charDir, 'logs');
|
|
const entries = await getEntries(this.logDir);
|
|
for(const entry of entries)
|
|
if(entry.name.substr(-4) === '.idx') {
|
|
const file = await new Promise<File>((s, j) => (<FileEntry>entry).file(s, j));
|
|
const buffer = await readAsArrayBuffer(file);
|
|
const dv = new DataView(buffer);
|
|
let offset = dv.getUint8(0);
|
|
const name = decoder.decode(new DataView(buffer, 1, offset++));
|
|
const index: {[key: number]: number} = {};
|
|
for(; offset < dv.byteLength; offset += 7) {
|
|
const key = dv.getUint16(offset);
|
|
index[key] = dv.getUint32(offset + 2) << 8 | dv.getUint8(offset + 6);
|
|
}
|
|
this.index[entry.name.slice(0, -4).toLowerCase()] = {name, index};
|
|
}
|
|
});
|
|
}
|
|
|
|
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
this.logDir.getFile(conversation.key, {create: true}, (file) => {
|
|
const serialized = serializeMessage(message);
|
|
const date = Math.floor(message.time.getTime() / dayMs);
|
|
let indexBuffer: {}[] | undefined;
|
|
let index = this.index[conversation.key];
|
|
if(index !== undefined) {
|
|
if(index.index[date] === undefined) indexBuffer = [];
|
|
} else {
|
|
index = this.index[conversation.key] = {name: conversation.name, index: {}};
|
|
const nameLength = getByteLength(conversation.name);
|
|
indexBuffer = [String.fromCharCode(nameLength), conversation.name];
|
|
}
|
|
if(indexBuffer !== undefined)
|
|
file.getMetadata((data) => {
|
|
index!.index[date] = data.size;
|
|
const dv = new DataView(new ArrayBuffer(7));
|
|
dv.setUint16(0, date);
|
|
dv.setUint32(2, data.size >> 8);
|
|
dv.setUint8(6, data.size % 256);
|
|
indexBuffer!.push(dv);
|
|
this.logDir.getFile(`${conversation.key}.idx`, {create: true}, (indexFile) => {
|
|
indexFile.createWriter((writer) => writer.write(new Blob(indexBuffer)), reject);
|
|
}, reject);
|
|
}, reject);
|
|
file.createWriter((writer) => writer.write(serialized), reject);
|
|
resolve();
|
|
}, reject);
|
|
});
|
|
}
|
|
|
|
async getBacklog(conversation: Conversation): Promise<Conversation.Message[]> {
|
|
const file = await getFile(this.logDir, conversation.key);
|
|
if(file === undefined) return [];
|
|
let count = 20;
|
|
let messages = new Array<Conversation.Message>(count);
|
|
let pos = file.size;
|
|
while(pos > 0 && count > 0) {
|
|
const length = new DataView(await readAsArrayBuffer(file)).getUint16(0);
|
|
pos = pos - length - 2;
|
|
messages[--count] = deserializeMessage(await readAsArrayBuffer(file.slice(pos, pos + length))).message;
|
|
}
|
|
if(count !== 0) messages = messages.slice(count);
|
|
return messages;
|
|
}
|
|
|
|
async getLogs(key: string, date: Date): Promise<Conversation.Message[]> {
|
|
const file = await getFile(this.logDir, key);
|
|
if(file === undefined) return [];
|
|
const messages: Conversation.Message[] = [];
|
|
const day = date.getTime() / dayMs;
|
|
const index = this.index[key];
|
|
if(index === undefined) return [];
|
|
let pos = index.index[date.getTime() / dayMs];
|
|
if(pos === undefined) return [];
|
|
while(pos < file.size) {
|
|
const deserialized = deserializeMessage(await readAsArrayBuffer(file.slice(pos, pos + 51000)));
|
|
if(Math.floor(deserialized.message.time.getTime() / dayMs) !== day) break;
|
|
messages.push(deserialized.message);
|
|
pos += deserialized.end;
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
getLogDates(key: string): ReadonlyArray<Date> {
|
|
const entry = this.index[key];
|
|
if(entry === undefined) return [];
|
|
const dates = [];
|
|
for(const date in entry.index) //tslint:disable-line:forin
|
|
dates.push(new Date(parseInt(date, 10) * dayMs));
|
|
return dates;
|
|
}
|
|
|
|
get conversations(): ReadonlyArray<{id: string, name: string}> {
|
|
const conversations: {id: string, name: string}[] = [];
|
|
for(const key in this.index) conversations.push({id: key, name: this.index[key]!.name});
|
|
conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
|
|
return conversations;
|
|
}
|
|
}
|
|
|
|
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {
|
|
const file = await getFile(fs.root, 'settings');
|
|
if(file === undefined) return undefined;
|
|
return <GeneralSettings>JSON.parse(await readAsString(file));
|
|
}
|
|
|
|
export async function setGeneralSettings(value: GeneralSettings): Promise<void> {
|
|
const writer = await getWriter(fs.root, 'settings');
|
|
writer.write(new Blob([JSON.stringify(value)]));
|
|
}
|
|
|
|
async function getSettingsDir(character: string = core.connection.character): Promise<DirectoryEntry> {
|
|
return new Promise<DirectoryEntry>((resolve, reject) => {
|
|
fs.root.getDirectory(character, {create: true}, resolve, reject);
|
|
});
|
|
}
|
|
|
|
export class SettingsStore implements Settings.Store {
|
|
async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
|
|
const dir = await getSettingsDir(character);
|
|
const file = await getFile(dir, key);
|
|
if(file === undefined) return undefined;
|
|
return <Settings.Keys[K]>JSON.parse(await readAsString(file));
|
|
}
|
|
|
|
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
|
|
const writer = await getWriter(await getSettingsDir(), key);
|
|
writer.write(new Blob([JSON.stringify(value)]));
|
|
}
|
|
|
|
async getAvailableCharacters(): Promise<string[]> {
|
|
return (await getEntries(fs.root)).filter((x) => x.isDirectory).map((x) => x.name);
|
|
}
|
|
} |