looood / src /lib /storage-service.ts
looda3131's picture
Clean push without any binary history
cc276cc
/**
* @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<IDBPDatabase<WhisperLinkDBSchema>> | null = null;
constructor() {
this.isBrowser = typeof window !== 'undefined';
}
private getDB(): Promise<IDBPDatabase<WhisperLinkDBSchema>> {
if (!this.isBrowser) {
return Promise.reject(new Error("IndexedDB is not available in this environment."));
}
if (!this.dbPromise) {
this.dbPromise = openDB<WhisperLinkDBSchema>(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<T>(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<T>(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<void> {
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<Message[]> {
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<Message>): Promise<void> {
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<void> {
if (!this.isBrowser) return;
const db = await this.getDB();
await db.delete(MESSAGES_STORE, messageId);
}
async clearMessages(chatId: string): Promise<void> {
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<void> {
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<Contact[]> {
if (!this.isBrowser) return [];
const db = await this.getDB();
return await db.getAll(CONTACTS_STORE);
}
async saveGroups(groups: Group[]): Promise<void> {
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<Group[]> {
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<void> {
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<Blob | null> {
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<void> {
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<T>(key: string): Promise<T | null> {
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<void> {
if (!this.isBrowser) return;
const db = await this.getDB();
await db.put(SYNC_STORE, { chatId, timestamp });
}
async getLastSync(chatId: string): Promise<number> {
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();