import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import {tr} from "tc-shared/i18n/localize"; import {LogCategory, logDebug, logError, logInfo, logWarn} from "tc-shared/log"; import {ChatEvent} from "../ui/frames/side/AbstractConversationDefinitions"; const clientUniqueId2StoreName = uniqueId => "conversation-" + uniqueId; let currentDatabase: IDBDatabase; let databaseMode: "closed" | "opening" | "updating" | "open" = "closed"; /* will trigger only once, have to be re added */ const databaseStateChangedCallbacks: (() => void)[] = []; async function requestDatabase() { while(true) { if(databaseMode === "open") { return; } else if(databaseMode === "opening" || databaseMode === "updating") { await new Promise(resolve => databaseStateChangedCallbacks.push(resolve)); } else if(databaseMode === "closed") { try { await doOpenDatabase(false); } catch (error) { currentDatabase = undefined; if(databaseMode !== "closed") { databaseMode = "closed"; fireDatabaseStateChanged(); } throw error; } } } } async function executeClose() { currentDatabase.close(); /* We don't await the close since for some reason the onclose callback never triggers */ /* await new Promise(resolve => currentDatabase.onclose = resolve); */ currentDatabase = undefined; } type DatabaseUpdateRequest = (database: IDBDatabase) => void; const databaseUpdateRequests: DatabaseUpdateRequest[] = []; async function requestDatabaseUpdate(callback: (database: IDBDatabase) => void) : Promise { while(true) { if(databaseMode === "opening") { await requestDatabase(); } else if(databaseMode === "updating") { databaseUpdateRequests.push(callback); await requestDatabase(); if(databaseUpdateRequests.indexOf(callback) === -1) return; } else if(databaseMode === "open") { databaseMode = "updating"; await executeClose(); break; } else if(databaseMode === "closed") { databaseMode = "updating"; break; } } /* lets update the database */ databaseMode = "updating"; fireDatabaseStateChanged(); databaseUpdateRequests.push(callback); await doOpenDatabase(true); } function fireDatabaseStateChanged() { while(databaseStateChangedCallbacks.length > 0) { try { databaseStateChangedCallbacks.pop()(); } catch (error) { logError(LogCategory.CHAT, tr("Database ready callback throw an unexpected exception: %o"), error); } } } let cacheImportUniqueKeyId = 0; async function importChatsFromCacheStorage(database: IDBDatabase) { if(!(await caches.has("chat_history"))) { return; } logInfo(LogCategory.CHAT, tr("Importing old chats saved via cache storage. This may take some moments.")); let chatEvents = {}; const cache = await caches.open("chat_history"); for(const chat of await cache.keys()) { try { if(!chat.url.startsWith("https://_local_cache/cache_request_")) { logWarn(LogCategory.CHAT, tr("Skipping importing chat %s because URL does not match."), chat.url); continue; } const clientUniqueId = chat.url.substring(35).split("_")[1]; const events: ChatEvent[] = chatEvents[clientUniqueId] || (chatEvents[clientUniqueId] = []); const data = await (await cache.match(chat)).json(); if(!Array.isArray(data)) { throw tr("array expected"); } for(const event of data) { events.push({ type: "message", timestamp: event["timestamp"], isOwnMessage: event["sender"] === "self", uniqueId: "ci-m-" + event["timestamp"] + "-" + (++cacheImportUniqueKeyId), message: { message: event["message"], timestamp: event["timestamp"], sender_database_id: event["sender_database_id"], sender_name: event["sender_name"], sender_unique_id: event["sender_unique_id"] } }); } } catch (error) { logWarn(LogCategory.CHAT, tr("Skipping importing chat %s because of an error: %o"), chat?.url, error); } } const clientUniqueIds = Object.keys(chatEvents); if(clientUniqueIds.length === 0) { return; } logInfo(LogCategory.CHAT, tr("Found %d old chats. Importing."), clientUniqueIds.length); await requestDatabaseUpdate(database => { for(const uniqueId of clientUniqueIds) { doInitializeUser(uniqueId, database); } }); await requestDatabase(); for(const uniqueId of clientUniqueIds) { const storeName = clientUniqueId2StoreName(uniqueId); const transaction = currentDatabase.transaction(storeName, "readwrite"); const store = transaction.objectStore(storeName); chatEvents[uniqueId].forEach(event => { store.put(event); }); await new Promise(resolve => store.transaction.oncomplete = resolve); } logInfo(LogCategory.CHAT, tr("All old chats have been imported. Deleting old data.")); await caches.delete("chat_history"); } async function doOpenDatabase(forceUpgrade: boolean) { if(!('indexedDB' in window)) { loader.critical_error(tr("Missing Indexed DB support")); throw tr("Missing Indexed DB support"); } if(databaseMode === "closed") { databaseMode = "opening"; fireDatabaseStateChanged(); } let localVersion = parseInt(localStorage.getItem("indexeddb-private-conversations-version") || "0"); let upgradePerformed = false; while(true) { const openRequest = indexedDB.open("private-conversations", forceUpgrade ? localVersion + 1 : undefined); openRequest.onupgradeneeded = event => { if(event.oldVersion === 0) { /* database newly created */ importChatsFromCacheStorage(openRequest.result).catch(error => { logWarn(LogCategory.CHAT, tr("Failed to import old chats from cache storage: %o"), error); }); } upgradePerformed = true; while (databaseUpdateRequests.length > 0) { try { databaseUpdateRequests.pop()(openRequest.result); } catch (error) { logError(LogCategory.CHAT, tr("Database update callback throw an unexpected exception: %o"), error); } } }; const database = await new Promise((resolve, reject) => { openRequest.onblocked = () => { reject(tr("Failed to open/upgrade the private chat database.\nPlease close all other instances of the TeaWeb client.")); }; openRequest.onerror = () => { logWarn(LogCategory.CHAT, tr("Private conversation history opening error: %o"), openRequest.error); reject(openRequest.error.message); }; openRequest.onsuccess = () => resolve(openRequest.result); }); localStorage.setItem("indexeddb-private-conversations-version", database.version.toString()); if(!upgradePerformed && forceUpgrade) { logWarn(LogCategory.CHAT, tr("Opened private conversations database, with an update, but update didn't happened. Trying again.")); database.close(); await new Promise(resolve => database.onclose = resolve); continue; } database.onversionchange = () => { logDebug(LogCategory.CHAT, tr("Received external database version change. Closing database.")); databaseMode = "closed"; executeClose(); }; currentDatabase = database; databaseMode = "open"; fireDatabaseStateChanged(); break; } } function doInitializeUser(uniqueId: string, database: IDBDatabase) { const storeId = clientUniqueId2StoreName(uniqueId); if(database.objectStoreNames.contains(storeId)) { return; } const store = database.createObjectStore(storeId, { keyPath: "databaseId", autoIncrement: true }); store.createIndex("timestamp", "timestamp", { unique: false }); store.createIndex("uniqueId", "uniqueId", { unique: false }); store.createIndex("type", "type", { unique: false }); } async function initializeUser(uniqueId: string) { await requestDatabase(); const storeId = clientUniqueId2StoreName(uniqueId); if(currentDatabase.objectStoreNames.contains(storeId)) return; await requestDatabaseUpdate(database => doInitializeUser(uniqueId, database)); } loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { priority: 0, name: "Chat history setup", function: async () => { try { await requestDatabase(); logDebug(LogCategory.CHAT, tr("Successfully initialized private conversation history database")); } catch (error) { logError(LogCategory.CHAT, tr("Failed to initialize private conversation history database: %o"), error); logError(LogCategory.CHAT, tr("Do not saving the private conversation chat.")); } } }); export async function queryConversationEvents(clientUniqueId: string, query: { begin: number, end: number, direction: "backwards" | "forwards", limit: number }) : Promise<{ events: (ChatEvent & { databaseId: number })[], hasMore: boolean }> { const storeName = clientUniqueId2StoreName(clientUniqueId); await requestDatabase(); if(!currentDatabase.objectStoreNames.contains(storeName)) { return { events: [], hasMore: false }; } const transaction = currentDatabase.transaction(storeName, "readonly"); const store = transaction.objectStore(storeName); const cursor = store.index("timestamp").openCursor(IDBKeyRange.bound(query.end, query.begin, false, false), query.direction === "backwards" ? "prev" : "next"); const events = []; let hasMoreEvents = false; await new Promise((resolve, reject) => { cursor.onsuccess = () => { if(!cursor.result) { /* no more results */ resolve(); return; } if(events.length > query.limit) { hasMoreEvents = true; resolve(); return; } events.push(cursor.result.value); cursor.result.continue(); }; cursor.onerror = () => reject(cursor.error); }); return { events: events, hasMore: hasMoreEvents }; } export async function registerConversationEvent(clientUniqueId: string, event: ChatEvent) : Promise { await initializeUser(clientUniqueId); const storeName = clientUniqueId2StoreName(clientUniqueId); await requestDatabase(); const transaction = currentDatabase.transaction(storeName, "readwrite"); const store = transaction.objectStore(storeName); store.put(event); }