/** * @fileoverview A centralized service for managing client-side storage. * This service uses IndexedDB for persistent message storage, ensuring * chats are saved locally on the user's device. */ import type { Message, Contact, Group } from '@/lib/types'; import { openDB, IDBPDatabase, DBSchema } from 'idb'; const DB_NAME = 'WhisperLinkDB_v2'; const DB_VERSION = 4; // Incremented version to add new stores const MESSAGES_STORE = 'messages'; const CONTACTS_STORE = 'contacts'; const GROUPS_STORE = 'groups'; const SYNC_STORE = 'syncInfo'; const MEDIA_CACHE_STORE = 'media-cache'; const DATA_CACHE_STORE = 'data-cache'; // Define the database schema for type safety with the idb library interface WhisperLinkDBSchema extends DBSchema { [MESSAGES_STORE]: { key: string; value: Message & { chatId: string }; // Ensure chatId is part of the value indexes: { 'chatId': string }; }; [CONTACTS_STORE]: { key: string; // uid value: Contact; }; [GROUPS_STORE]: { key: string; // id value: Group; }; [SYNC_STORE]: { key: string; value: { chatId: string; timestamp: number }; }; [MEDIA_CACHE_STORE]: { key: string; // The fileKey from R2 value: { fileKey: string; blob: Blob; timestamp: number; }; }; [DATA_CACHE_STORE]: { key: string; value: any; } } class StorageService { private isBrowser: boolean; private dbPromise: Promise> | null = null; constructor() { this.isBrowser = typeof window !== 'undefined'; } private getDB(): Promise> { if (!this.isBrowser) { return Promise.reject(new Error("IndexedDB is not available in this environment.")); } if (!this.dbPromise) { this.dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db, oldVersion, newVersion, transaction) { console.log(`Upgrading IndexedDB from version ${oldVersion} to ${newVersion}...`); if (!db.objectStoreNames.contains(MESSAGES_STORE)) { const messageStore = db.createObjectStore(MESSAGES_STORE, { keyPath: 'id' }); messageStore.createIndex('chatId', 'chatId', { unique: false }); console.log(`Created object store: ${MESSAGES_STORE} with index 'chatId'.`); } if (!db.objectStoreNames.contains(CONTACTS_STORE)) { db.createObjectStore(CONTACTS_STORE, { keyPath: 'uid' }); console.log(`Created object store: ${CONTACTS_STORE}.`); } if (!db.objectStoreNames.contains(GROUPS_STORE)) { db.createObjectStore(GROUPS_STORE, { keyPath: 'id' }); console.log(`Created object store: ${GROUPS_STORE}.`); } if (!db.objectStoreNames.contains(SYNC_STORE)) { db.createObjectStore(SYNC_STORE, { keyPath: 'chatId' }); console.log(`Created object store: ${SYNC_STORE}.`); } if (!db.objectStoreNames.contains(MEDIA_CACHE_STORE)) { db.createObjectStore(MEDIA_CACHE_STORE, { keyPath: 'fileKey' }); console.log(`Created object store: ${MEDIA_CACHE_STORE}.`); } if (!db.objectStoreNames.contains(DATA_CACHE_STORE)) { db.createObjectStore(DATA_CACHE_STORE, { keyPath: 'key' }); console.log(`Created object store: ${DATA_CACHE_STORE}.`); } }, }); } return this.dbPromise; } // --- LocalStorage Methods --- setItem(key: string, value: T): void { if (!this.isBrowser) return; try { window.localStorage.setItem(key, JSON.stringify(value)); } catch (error) { console.error(`Error setting localStorage key "${key}":`, error); } } getItem(key: string): T | null { if (!this.isBrowser) return null; try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : null; } catch (error) { console.error(`Error reading localStorage key "${key}":`, error); return null; } } removeItem(key: string): void { if (!this.isBrowser) return; try { window.localStorage.removeItem(key); } catch (error) { console.error(`Error removing localStorage key "${key}":`, error); } } // --- IndexedDB Methods for Messages, Contacts, Groups --- async saveMessages(chatId: string, messages: Message[]): Promise { if (!this.isBrowser) return; try { const db = await this.getDB(); const tx = db.transaction(MESSAGES_STORE, 'readwrite'); await Promise.all(messages.map(message => tx.store.put({ ...message, chatId }))); await tx.done; } catch (error) { console.error("Failed to save messages to IndexedDB:", error); } } async getMessages(chatId: string): Promise { if (!this.isBrowser) return []; const db = await this.getDB(); const messages = await db.getAllFromIndex(MESSAGES_STORE, 'chatId', chatId); return messages.sort((a, b) => a.timestamp - b.timestamp); } async updateMessage(chatId: string, messageId: string, updates: Partial): Promise { if (!this.isBrowser) return; const db = await this.getDB(); const message = await db.get(MESSAGES_STORE, messageId); if (message) { const updatedMessage = { ...message, ...updates }; await db.put(MESSAGES_STORE, updatedMessage); } } async deleteMessage(chatId: string, messageId: string): Promise { if (!this.isBrowser) return; const db = await this.getDB(); await db.delete(MESSAGES_STORE, messageId); } async clearMessages(chatId: string): Promise { if (!this.isBrowser) return; const db = await this.getDB(); const tx = db.transaction(MESSAGES_STORE, 'readwrite'); const index = tx.store.index('chatId'); for await (const cursor of index.iterate(chatId)) { await cursor.delete(); } await tx.done; } async saveContacts(contacts: Contact[]): Promise { if (!this.isBrowser) return; const db = await this.getDB(); const tx = db.transaction(CONTACTS_STORE, 'readwrite'); await Promise.all(contacts.map(contact => tx.store.put(contact))); await tx.done; } async getContacts(): Promise { if (!this.isBrowser) return []; const db = await this.getDB(); return await db.getAll(CONTACTS_STORE); } async saveGroups(groups: Group[]): Promise { if (!this.isBrowser) return; const db = await this.getDB(); const tx = db.transaction(GROUPS_STORE, 'readwrite'); await Promise.all(groups.map(group => tx.store.put(group))); await tx.done; } async getGroups(): Promise { if (!this.isBrowser) return []; const db = await this.getDB(); return await db.getAll(GROUPS_STORE); } // --- Media Caching Methods --- async cacheMedia(fileKey: string, blob: Blob): Promise { if (!this.isBrowser) return; const db = await this.getDB(); await db.put(MEDIA_CACHE_STORE, { fileKey, blob, timestamp: Date.now() }); } async getCachedMedia(fileKey: string): Promise { if (!this.isBrowser) return null; try { const db = await this.getDB(); const record = await db.get(MEDIA_CACHE_STORE, fileKey); return record?.blob || null; } catch (error) { console.error(`Failed to get cached media for key ${fileKey}:`, error); return null; } } // --- General Data Caching --- async saveCachedData(key: string, data: any): Promise { if (!this.isBrowser) return; try { const db = await this.getDB(); await db.put(DATA_CACHE_STORE, { key, data }); } catch (error) { console.error(`Failed to save data to cache with key ${key}:`, error); } } async getCachedData(key: string): Promise { if (!this.isBrowser) return null; try { const db = await this.getDB(); const result = await db.get(DATA_CACHE_STORE, key); return result?.data || null; } catch (error) { console.error(`Failed to retrieve cached data with key ${key}:`, error); return null; } } // --- Sync Info Management --- async updateLastSync(chatId: string, timestamp: number): Promise { if (!this.isBrowser) return; const db = await this.getDB(); await db.put(SYNC_STORE, { chatId, timestamp }); } async getLastSync(chatId: string): Promise { if (!this.isBrowser) return 0; const db = await this.getDB(); const syncInfo = await db.get(SYNC_STORE, chatId); return syncInfo?.timestamp || 0; } } // Export a singleton instance of the service export const storageService = new StorageService();