| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { MessageDO, SettingDO } from '../home/types' |
| | | |
| | | import { toRaw } from 'vue' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { ImConversationType } from './constants' |
| | | |
| | | export const DB_SCHEMA_VERSION = 2 |
| | | |
| | | export type DbStoreName = |
| | | | 'channels' |
| | | | 'conversationReads' |
| | | | 'conversations' |
| | | | 'friendRequests' |
| | | | 'friends' |
| | | | 'groupMembers' |
| | | | 'groupRequests' |
| | | | 'groups' |
| | | | 'messages' |
| | | | 'settings' |
| | | |
| | | export type DbTransaction = IDBTransaction |
| | | |
| | | /** IM æ¬å°åå¨ key */ |
| | | export const StorageKeys = { |
| | | localStorage: { |
| | | /** ä¾§è¾¹æ 宽度ï¼ä¸ä¸ª Tab å
±ç¨ä¸ä»½è®°å¿ */ |
| | | asideWidth: 'im:aside', |
| | | /** ä¼è¯å表置顶æå å±å¼æ */ |
| | | conversationPinnedExpanded: 'im:conversation:pinnedExpanded' |
| | | }, |
| | | settings: { |
| | | /** ç§èæ¶æ¯æå游æ */ |
| | | privateMessageMaxId: 'privateMessageMaxId', |
| | | /** ç¾¤èæ¶æ¯æåæ¸¸æ */ |
| | | groupMessageMaxId: 'groupMessageMaxId', |
| | | /** é¢éæ¶æ¯æå游æ */ |
| | | channelMessageMaxId: 'channelMessageMaxId', |
| | | /** æè¿è½¬åä¼è¯ key å表 */ |
| | | recentForwardConversationKeys: 'recentForwardConversationKeys', |
| | | // ç¶æäºä»¶è¡¥å¿å¢éæå游æ ï¼ä¸ä¸é¢æ¶æ¯ maxId 游æ å
±ç¨åä¸ settings keyspaceï¼ç»ä¸ç»è®°å¨æ¤é¿å
æ keyï¼ |
| | | // èµ° update_time + id å¤å游æ ï¼éåæ¡ maxIdï¼ï¼æ
ç¨ PullCursor åç¼åºåè¯ä¹ |
| | | /** 好åå
³ç³»å¢éæå游æ */ |
| | | friendPullCursor: 'friendPullCursor', |
| | | /** 好åç³è¯·å¢éæå游æ */ |
| | | friendRequestPullCursor: 'friendRequestPullCursor', |
| | | /** å 群ç³è¯·å¢éæå游æ */ |
| | | groupRequestPullCursor: 'groupRequestPullCursor', |
| | | /** ä¼è¯è¯»ä½ç½®å¢éæå游æ */ |
| | | conversationReadPullCursor: 'conversationReadPullCursor' |
| | | } |
| | | } as const |
| | | |
| | | let currentDb: IDBDatabase | null = null |
| | | let currentUserId: null | number = null |
| | | let currentSession = 0 |
| | | |
| | | /** æ ¡éªå½å IM IndexedDB session 仿æ */ |
| | | export function isCurrentDbSession(session: number): boolean { |
| | | return session === currentSession |
| | | } |
| | | |
| | | /** è·åå½å IM IndexedDB session */ |
| | | export function getDbSession(): number { |
| | | return currentSession |
| | | } |
| | | |
| | | /** æ¼æ¥å½åç¨æ· IM DB åç§° */ |
| | | function getDbName(userId: number): string { |
| | | return `im:${userId}` |
| | | } |
| | | |
| | | /** å
è£
IndexedDB request */ |
| | | function requestToPromise<T = unknown>(request: IDBRequest<T>): Promise<T> { |
| | | return new Promise((resolve, reject) => { |
| | | request.addEventListener('success', () => resolve(request.result)) |
| | | request.addEventListener('error', () => reject(request.error)) |
| | | }) |
| | | } |
| | | |
| | | /** çå¾
äºå¡å®æ */ |
| | | function transactionDone(transaction: DbTransaction): Promise<void> { |
| | | return new Promise((resolve, reject) => { |
| | | transaction.addEventListener('complete', () => resolve()) |
| | | transaction.addEventListener('error', () => reject(transaction.error)) |
| | | transaction.addEventListener('abort', () => reject(transaction.error)) |
| | | }) |
| | | } |
| | | |
| | | /** åå»ºç´¢å¼ */ |
| | | function createIndex( |
| | | store: IDBObjectStore, |
| | | name: string, |
| | | keyPath: string | string[], |
| | | options?: IDBIndexParameters |
| | | ) { |
| | | if (!store.indexNames.contains(name)) { |
| | | store.createIndex(name, keyPath, options) |
| | | } |
| | | } |
| | | |
| | | /** åå§å schema */ |
| | | function upgradeSchema(db: IDBDatabase) { |
| | | if (!db.objectStoreNames.contains('conversations')) { |
| | | const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' }) |
| | | createIndex(store, 'lastSendTime', 'lastSendTime') |
| | | } |
| | | if (!db.objectStoreNames.contains('conversationReads')) { |
| | | const store = db.createObjectStore('conversationReads', { keyPath: 'clientConversationId' }) |
| | | createIndex(store, 'conversationType+targetId', ['conversationType', 'targetId'], { |
| | | unique: true |
| | | }) |
| | | } |
| | | if (!db.objectStoreNames.contains('messages')) { |
| | | const store = db.createObjectStore('messages', { keyPath: 'messageKey' }) |
| | | createIndex(store, 'clientConversationId', 'clientConversationId') |
| | | createIndex(store, 'clientConversationId+sendTime', ['clientConversationId', 'sendTime']) |
| | | createIndex(store, 'clientMessageId', 'clientMessageId', { unique: true }) |
| | | } |
| | | if (!db.objectStoreNames.contains('friends')) { |
| | | const store = db.createObjectStore('friends', { keyPath: 'id' }) |
| | | createIndex(store, 'friendUserId', 'friendUserId', { unique: true }) |
| | | createIndex(store, 'status', 'status') |
| | | } |
| | | if (!db.objectStoreNames.contains('friendRequests')) { |
| | | const store = db.createObjectStore('friendRequests', { keyPath: 'id' }) |
| | | createIndex(store, 'status', 'status') |
| | | createIndex(store, 'createTime', 'createTime') |
| | | } |
| | | if (!db.objectStoreNames.contains('groups')) { |
| | | const store = db.createObjectStore('groups', { keyPath: 'id' }) |
| | | createIndex(store, 'name', 'name') |
| | | createIndex(store, 'status', 'status') |
| | | } |
| | | if (!db.objectStoreNames.contains('groupMembers')) { |
| | | const store = db.createObjectStore('groupMembers', { keyPath: 'id' }) |
| | | createIndex(store, 'groupId', 'groupId') |
| | | createIndex(store, 'groupId+userId', ['groupId', 'userId'], { unique: true }) |
| | | } |
| | | if (!db.objectStoreNames.contains('groupRequests')) { |
| | | const store = db.createObjectStore('groupRequests', { keyPath: 'id' }) |
| | | createIndex(store, 'status', 'status') |
| | | createIndex(store, 'createTime', 'createTime') |
| | | } |
| | | if (!db.objectStoreNames.contains('channels')) { |
| | | const store = db.createObjectStore('channels', { keyPath: 'id' }) |
| | | createIndex(store, 'status', 'status') |
| | | createIndex(store, 'sort', 'sort') |
| | | } |
| | | if (!db.objectStoreNames.contains('settings')) { |
| | | db.createObjectStore('settings', { keyPath: 'key' }) |
| | | } |
| | | } |
| | | |
| | | /** æå¼ IM IndexedDB */ |
| | | function openDb(name: string): Promise<IDBDatabase> { |
| | | return new Promise((resolve, reject) => { |
| | | const request = indexedDB.open(name, DB_SCHEMA_VERSION) |
| | | // å建æå级对象ä»åº |
| | | request.addEventListener('upgradeneeded', () => upgradeSchema(request.result)) |
| | | // è¿åå¯å¤ç¨è¿æ¥ |
| | | request.addEventListener('success', () => resolve(request.result)) |
| | | request.addEventListener('error', () => reject(request.error)) |
| | | }) |
| | | } |
| | | |
| | | /** åå§åå½åç¨æ· IM DB */ |
| | | export async function initDb(): Promise<void> { |
| | | const userId = getCurrentUserId() |
| | | if (!Number.isFinite(userId) || userId <= 0) { |
| | | throw new Error('å½åç¨æ·ä¸åå¨ï¼æ æ³åå§å IM DB') |
| | | } |
| | | if (currentDb && currentUserId === userId) { |
| | | return |
| | | } |
| | | currentDb?.close() |
| | | currentSession++ |
| | | currentUserId = userId |
| | | currentDb = await openDb(getDbName(userId)) |
| | | } |
| | | |
| | | /** å
³éå½å IM DB è¿æ¥ */ |
| | | function closeDbConnection() { |
| | | currentDb?.close() |
| | | currentDb = null |
| | | currentUserId = null |
| | | } |
| | | |
| | | /** è·åå½å IM DB */ |
| | | function getRawDb(): IDBDatabase { |
| | | if (!currentDb) { |
| | | throw new Error('IM DB æªåå§å') |
| | | } |
| | | return currentDb |
| | | } |
| | | |
| | | /** æ ¡éªå次åå
¥ session */ |
| | | function guardSession(session: number) { |
| | | if (!isCurrentDbSession(session)) { |
| | | throw new Error('IM DB session 已失æ') |
| | | } |
| | | } |
| | | |
| | | /** å
éå¯å
¥åºå¯¹è±¡ */ |
| | | function toDbValue<T>(value: T): T { |
| | | return cloneDbValue(value) as T |
| | | } |
| | | |
| | | /** 转æ¢ä¸º IndexedDB å¯å
é对象 */ |
| | | function cloneDbValue(value: unknown): unknown { |
| | | const raw = toRaw(value) |
| | | if (Array.isArray(raw)) { |
| | | return raw.map((item) => cloneDbValue(item)) |
| | | } |
| | | if (!raw || typeof raw !== 'object') { |
| | | return raw |
| | | } |
| | | const prototype = Object.getPrototypeOf(raw) |
| | | if (prototype !== Object.prototype && prototype !== null) { |
| | | return raw |
| | | } |
| | | return Object.fromEntries( |
| | | Object.entries(raw as Record<string, unknown>).map(([key, item]) => [key, cloneDbValue(item)]) |
| | | ) |
| | | } |
| | | |
| | | class DbClient { |
| | | /** æ¸
空 store è®°å½ */ |
| | | async clearStore(storeName: DbStoreName, tx?: DbTransaction): Promise<void> { |
| | | if (tx) { |
| | | await requestToPromise(tx.objectStore(storeName).clear()) |
| | | return |
| | | } |
| | | await this.transaction([storeName], 'readwrite', (tx) => this.clearStore(storeName, tx)) |
| | | } |
| | | |
| | | /** å é¤è®°å½ */ |
| | | async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTransaction): Promise<void> { |
| | | if (tx) { |
| | | await requestToPromise(tx.objectStore(storeName).delete(key)) |
| | | return |
| | | } |
| | | await this.transaction([storeName], 'readwrite', (tx) => this.delete(storeName, key, tx)) |
| | | } |
| | | |
| | | /** æç´¢å¼å é¤è®°å½ */ |
| | | async deleteByIndex( |
| | | storeName: DbStoreName, |
| | | indexName: string, |
| | | query: IDBKeyRange | IDBValidKey, |
| | | tx?: DbTransaction |
| | | ): Promise<void> { |
| | | if (!tx) { |
| | | await this.transaction([storeName], 'readwrite', (tx) => |
| | | this.deleteByIndex(storeName, indexName, query, tx) |
| | | ) |
| | | return |
| | | } |
| | | const index = tx.objectStore(storeName).index(indexName) |
| | | await new Promise<void>((resolve, reject) => { |
| | | const request = index.openCursor(query) |
| | | request.addEventListener('error', () => reject(request.error)) |
| | | request.addEventListener('success', () => { |
| | | const cursor = request.result |
| | | if (!cursor) { |
| | | resolve() |
| | | return |
| | | } |
| | | cursor.delete() |
| | | cursor.continue() |
| | | }) |
| | | }) |
| | | } |
| | | |
| | | /** è·ååæ¡è®°å½ */ |
| | | async get<T>( |
| | | storeName: DbStoreName, |
| | | key: IDBValidKey, |
| | | tx?: DbTransaction |
| | | ): Promise<T | undefined> { |
| | | if (tx) { |
| | | return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key)) |
| | | } |
| | | return this.transaction<T | undefined>([storeName], 'readonly', (tx) => |
| | | this.get<T>(storeName, key, tx) |
| | | ) |
| | | } |
| | | |
| | | /** è·å store å
¨éè®°å½ */ |
| | | async getAll<T>(storeName: DbStoreName, tx?: DbTransaction): Promise<T[]> { |
| | | if (tx) { |
| | | return requestToPromise<T[]>(tx.objectStore(storeName).getAll()) |
| | | } |
| | | return this.transaction<T[]>([storeName], 'readonly', (tx) => this.getAll<T>(storeName, tx)) |
| | | } |
| | | |
| | | /** æç´¢å¼è·åè®°å½å表 */ |
| | | async getAllByIndex<T>( |
| | | storeName: DbStoreName, |
| | | indexName: string, |
| | | query?: IDBKeyRange | IDBValidKey, |
| | | tx?: DbTransaction |
| | | ): Promise<T[]> { |
| | | if (tx) { |
| | | return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query)) |
| | | } |
| | | return this.transaction<T[]>([storeName], 'readonly', (tx) => |
| | | this.getAllByIndex<T>(storeName, indexName, query, tx) |
| | | ) |
| | | } |
| | | |
| | | /** æå¯ä¸ç´¢å¼è·ååæ¡è®°å½ */ |
| | | async getByIndex<T>( |
| | | storeName: DbStoreName, |
| | | indexName: string, |
| | | query: IDBKeyRange | IDBValidKey, |
| | | tx?: DbTransaction |
| | | ): Promise<T | undefined> { |
| | | if (tx) { |
| | | return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query)) |
| | | } |
| | | return this.transaction<T | undefined>([storeName], 'readonly', (tx) => |
| | | this.getByIndex<T>(storeName, indexName, query, tx) |
| | | ) |
| | | } |
| | | |
| | | /** æä¼è¯å页è·åæ¶æ¯ */ |
| | | async getMessageListByConversation( |
| | | clientConversationId: string, |
| | | options?: { beforeSendTime?: number; limit?: number }, |
| | | tx?: DbTransaction |
| | | ): Promise<MessageDO[]> { |
| | | const limit = options?.limit ?? 50 |
| | | const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER |
| | | const range = IDBKeyRange.bound( |
| | | [clientConversationId, 0], |
| | | [clientConversationId, upper], |
| | | false, |
| | | true |
| | | ) |
| | | const read = async (tx: DbTransaction): Promise<MessageDO[]> => { |
| | | const index = tx.objectStore('messages').index('clientConversationId+sendTime') |
| | | const out: MessageDO[] = [] |
| | | await new Promise<void>((resolve, reject) => { |
| | | // 仿°å°æ§è¯»åä¸é¡µ |
| | | const request = index.openCursor(range, 'prev') |
| | | request.addEventListener('error', () => reject(request.error)) |
| | | request.addEventListener('success', () => { |
| | | const cursor = request.result |
| | | if (!cursor || out.length >= limit) { |
| | | resolve() |
| | | return |
| | | } |
| | | out.push(cursor.value as MessageDO) |
| | | cursor.continue() |
| | | }) |
| | | }) |
| | | // æ°æ³¡æ¸²æéè¦ææ¶é´ååº |
| | | return out.toReversed() |
| | | } |
| | | if (tx) { |
| | | return read(tx) |
| | | } |
| | | return this.transaction<MessageDO[]>(['messages'], 'readonly', read) |
| | | } |
| | | |
| | | /** 读å设置 */ |
| | | async getSetting<T>(key: string, tx?: DbTransaction): Promise<T | undefined> { |
| | | const item = await this.get<SettingDO<T>>('settings', key, tx) |
| | | return item?.value |
| | | } |
| | | |
| | | /** åå
¥è®°å½ */ |
| | | async put<T>(storeName: DbStoreName, value: T, tx?: DbTransaction): Promise<void> { |
| | | if (tx) { |
| | | await requestToPromise(tx.objectStore(storeName).put(toDbValue(value))) |
| | | return |
| | | } |
| | | await this.transaction([storeName], 'readwrite', (tx) => this.put(storeName, value, tx)) |
| | | } |
| | | |
| | | /** åå
¥è®¾ç½® */ |
| | | async setSetting<T>(key: string, value: T, tx?: DbTransaction): Promise<void> { |
| | | await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx) |
| | | } |
| | | |
| | | /** æ§è¡äºå¡ */ |
| | | async transaction<T>( |
| | | storeNames: DbStoreName[], |
| | | mode: IDBTransactionMode, |
| | | runner: (tx: DbTransaction) => Promise<T> |
| | | ): Promise<T> { |
| | | // å¼å¯äºå¡åæ ¡éª session |
| | | const session = getDbSession() |
| | | guardSession(session) |
| | | const tx = getRawDb().transaction(storeNames, mode) |
| | | const done = transactionDone(tx) |
| | | let result: T |
| | | try { |
| | | // äºå¡å
åªæ§è¡ IndexedDB request é¾ |
| | | result = await runner(tx) |
| | | } catch (error) { |
| | | try { |
| | | tx.abort() |
| | | } catch {} |
| | | await done.catch(() => undefined) |
| | | throw error |
| | | } |
| | | // commit å忬¡æ ¡éª session |
| | | await done |
| | | guardSession(session) |
| | | return result |
| | | } |
| | | } |
| | | |
| | | const dbClient = new DbClient() |
| | | |
| | | /** è·åå½å IM DB client */ |
| | | export function getDb(): DbClient { |
| | | return dbClient |
| | | } |
| | | |
| | | /** å½åç¨æ·ä¼è¯ä¸»é® */ |
| | | export function getClientConversationId(type: number, targetId: number): string { |
| | | return `${type}:${targetId}` |
| | | } |
| | | |
| | | /** è§£æå½åç¨æ·ä¼è¯ä¸»é® */ |
| | | export function parseClientConversationId( |
| | | clientConversationId: string |
| | | ): null | { targetId: number; type: number; } { |
| | | const [typeText, targetIdText] = clientConversationId.split(':') |
| | | const type = Number(typeText) |
| | | const targetId = Number(targetIdText) |
| | | if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) { |
| | | return null |
| | | } |
| | | return { type, targetId } |
| | | } |
| | | |
| | | /** æå¡ç«¯æ¶æ¯ä¸»é® */ |
| | | export function getServerMessageKey(conversationType: number, id: number): string { |
| | | return `${conversationType}:${id}` |
| | | } |
| | | |
| | | /** 客æ·ç«¯ä¸´æ¶æ¶æ¯ä¸»é® */ |
| | | export function getClientMessageKey(clientMessageId: string): string { |
| | | return `client:${clientMessageId}` |
| | | } |
| | | |
| | | /** è§£ææ¬å°æ¶æ¯ä¸»é® */ |
| | | export function parseMessageKey( |
| | | messageKey: string |
| | | ): |
| | | | null |
| | | | { clientMessageId: string; kind: 'client'; } |
| | | | { conversationType: number; id: number; kind: 'server'; } { |
| | | if (!messageKey) { |
| | | return null |
| | | } |
| | | if (messageKey.startsWith('client:')) { |
| | | const clientMessageId = messageKey.slice('client:'.length) |
| | | return clientMessageId ? { kind: 'client', clientMessageId } : null |
| | | } |
| | | const [conversationTypeText, idText] = messageKey.split(':') |
| | | const conversationType = Number(conversationTypeText) |
| | | const id = Number(idText) |
| | | if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) { |
| | | return null |
| | | } |
| | | return { kind: 'server', conversationType, id } |
| | | } |
| | | |
| | | /** æ´æ°æ¶æ¯æå游æ */ |
| | | export async function setMessageMaxId( |
| | | conversationType: number, |
| | | maxId: number | undefined, |
| | | tx?: DbTransaction |
| | | ): Promise<void> { |
| | | if (!maxId) { |
| | | return |
| | | } |
| | | let key: string |
| | | switch (conversationType) { |
| | | case ImConversationType.CHANNEL: { |
| | | key = StorageKeys.settings.channelMessageMaxId |
| | | break |
| | | } |
| | | case ImConversationType.GROUP: { |
| | | key = StorageKeys.settings.groupMessageMaxId |
| | | break |
| | | } |
| | | case ImConversationType.PRIVATE: { |
| | | key = StorageKeys.settings.privateMessageMaxId |
| | | break |
| | | } |
| | | default: { |
| | | throw new Error(`æªç¥ IM ä¼è¯ç±»åï¼${conversationType}`) |
| | | } |
| | | } |
| | | const db = getDb() |
| | | const current = (await db.getSetting<number>(key, tx)) || 0 |
| | | if (maxId > current) { |
| | | await db.setSetting(key, maxId, tx) |
| | | } |
| | | } |
| | | |
| | | /** 忢å½å IM DB session */ |
| | | export async function stopRequests(): Promise<void> { |
| | | currentSession++ |
| | | const [ |
| | | { useMessageStoreWithOut }, |
| | | { useConversationStoreWithOut }, |
| | | { useFriendStoreWithOut }, |
| | | { useGroupStoreWithOut }, |
| | | { useChannelStoreWithOut }, |
| | | { useGroupRequestStoreWithOut }, |
| | | { useFaceStoreWithOut }, |
| | | { useRtcStore } |
| | | ] = await Promise.all([ |
| | | import('../home/store/messageStore'), |
| | | import('../home/store/conversationStore'), |
| | | import('../home/store/friendStore'), |
| | | import('../home/store/groupStore'), |
| | | import('../home/store/channelStore'), |
| | | import('../home/store/groupRequestStore'), |
| | | import('../home/store/faceStore'), |
| | | import('../home/store/rtcStore') |
| | | ]) |
| | | useMessageStoreWithOut().clear() |
| | | useConversationStoreWithOut().clear() |
| | | useFriendStoreWithOut().clear() |
| | | useGroupStoreWithOut().clear() |
| | | useChannelStoreWithOut().clear() |
| | | useGroupRequestStoreWithOut().clear() |
| | | useFaceStoreWithOut().clear() |
| | | useRtcStore().reset() |
| | | useRtcStore().clearGroupCallCache() |
| | | closeDbConnection() |
| | | } |