| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation } from './types' |
| | | |
| | | import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' |
| | | import { useRoute } from 'vue-router' |
| | | |
| | | import { preferences } from '#/packages/preferences/src' |
| | | |
| | | import { ImConversationType } from '../utils/constants' |
| | | import { initDb, stopRequests, StorageKeys } from '../utils/db' |
| | | import { ContextMenu, ToolBar } from './components' |
| | | import { GroupInfoCard } from './components/group' |
| | | import { RtcCallContainer } from './components/rtc' |
| | | import { UserInfoCard } from './components/user' |
| | | import { useMessagePuller } from './composables/useMessagePuller' |
| | | import { useMessageSender } from './composables/useMessageSender' |
| | | import { useVoicePlayer } from './composables/useVoicePlayer' |
| | | import { useChannelStore } from './store/channelStore' |
| | | import { useConversationStore } from './store/conversationStore' |
| | | import { useFaceStore } from './store/faceStore' |
| | | import { useFriendStore } from './store/friendStore' |
| | | import { useGroupRequestStore } from './store/groupRequestStore' |
| | | import { useGroupStore } from './store/groupStore' |
| | | import { useMessageStore } from './store/messageStore' |
| | | import { useImWebSocketStore } from './store/websocketStore' |
| | | |
| | | defineOptions({ name: 'ImIndex' }) |
| | | |
| | | const route = useRoute() |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const webSocketStore = useImWebSocketStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const groupRequestStore = useGroupRequestStore() |
| | | const faceStore = useFaceStore() |
| | | const channelStore = useChannelStore() |
| | | const { pullOnce, cancelPull } = useMessagePuller() |
| | | const { readActive, syncPrivateReadStatus } = useMessageSender() |
| | | const voicePlayer = useVoicePlayer() |
| | | const childRouteReady = ref(false) // åè·¯ç±æ¯å¦å¯æè½½ |
| | | |
| | | /** åå§åï¼å
忬å°ç¼å让é¦å±ç«å³æ¸²æï¼åè¿ç«¯å·æ°ææ°æ°æ®ï¼æåå»ºå®æ¶éä¿¡æç¦»çº¿æ¶æ¯ */ |
| | | onMounted(async () => { |
| | | // 0.1 ç³»ç»è¡¨æ
å
åå°é¢æï¼ç¬ç«é¾è·¯ä¸é¦å± IDB / è¿ç«¯æåå¹¶åï¼æ¶é¤è¡¨æ
颿¿é¦æ¬¡å±å¼ç½å±ï¼å¤±è´¥ä»
è®°æ¥å¿ï¼ä¸é»å¡ä¸»æµç¨ |
| | | void faceStore.ensureFacePackList().catch((error) => console.warn('[IM] åå°é¢æè¡¨æ
å
失败', error)) |
| | | // 1.1 æ´æ®µ loading=true 黿ä¼è¯å表æå¨åç + WebSocket æ®éæ¶æ¯è¿ç¼å²ï¼é¿å
connect å° pullOnce ä¹é´æ¶å°ç宿¶æ¶æ¯æ¨è¿ maxId å¯¼è´ pull è·³è¿æçº¿ç§¯åæ¶æ¯ |
| | | conversationStore.loading = true |
| | | try { |
| | | // 1.2 æå¼å½åç¨æ· IM DB |
| | | await initDb() |
| | | // 1.3 å¤ä¸ª store å¹¶åä» IDB è¯»åæ¬å°ç¼å |
| | | const cacheResults = await Promise.all([ |
| | | conversationStore.loadConversationList(), |
| | | messageStore.loadMessageCursorList(), |
| | | friendStore.loadFriendData(), |
| | | groupStore.loadGroupList(), |
| | | channelStore.loadChannelList(), |
| | | groupRequestStore.loadGroupRequestList() |
| | | ]) |
| | | const hasFriendRows = cacheResults[2] |
| | | const hasGroupRows = cacheResults[3] |
| | | const hasChannelRows = cacheResults[4] |
| | | groupStore.markAllGroupActiveCallsExpired() |
| | | groupStore.markAllGroupMembersExpired() |
| | | childRouteReady.value = true |
| | | // 1.4 æç®¡ççç¾¤ä¸æªå¤çå 群ç³è¯·çº¢ç¹ï¼é¦ç»ç¨ unhandled-listï¼æå¡ç«¯ç´æ¥è¿æ»¤æªå¤çï¼è¯ä¹ç²¾åãå¯å¨è½»ï¼ï¼ |
| | | // pullGroupRequests åªå¨éè¿ / åç»è¡¥å¿æ¶è·ï¼è§ useMessagePuller.pullStateEventsï¼ï¼ä¸è¿é¦ç»ä¸»é¾è·¯ |
| | | void groupRequestStore |
| | | .fetchUnhandledGroupRequestList() |
| | | .catch((error) => console.warn('[IM] æåæªå¤çå 群ç³è¯·å¤±è´¥', error)) |
| | | |
| | | // 2. 好åä¸»æ°æ®æ¢å¤èµ° pullï¼ç¾¤å表ç¨å¿«ç
§å·æ°ï¼è¦ç离线æé´èªå·±çå
¥ç¾¤ / éç¾¤ç¶æåå |
| | | // 2.1 æç¼åï¼å¼æ¥èæ¯å¢éå·æ°ï¼å¤±è´¥ä»
è®°æ¥å¿ï¼IDB æ°æ®å·²ç»å¤æé¦å±ï¼pullOnce ä¹è½æ£å¸¸å
¥åºï¼ |
| | | // 2.2 æ ç¼åï¼é¦ç» / åè´¦å·ååï¼ï¼å¿
é¡» await + 失败æåºä¸ææ¬è½® onMountedï¼ |
| | | // å¦å pullOnce ä¼ç¨ senderId æ°åç»ä¼è¯èµ·åè½å° IDB åç»åºæ¬æ æ³èªæï¼æ ç¼å忝并å Promise.all çä¸ä¸ª RTT |
| | | const requiredFetches: Promise<unknown>[] = [] |
| | | if (hasFriendRows) { |
| | | void friendStore.pullFriends().catch((error) => console.warn('[IM] åå°å¢éæå¥½å失败', error)) |
| | | } else { |
| | | requiredFetches.push(friendStore.pullFriends()) |
| | | } |
| | | if (hasGroupRows) { |
| | | void groupStore.fetchGroupList(true).catch((error) => console.warn('[IM] åå°å·æ°ç¾¤å表失败', error)) |
| | | } else { |
| | | requiredFetches.push(groupStore.fetchGroupList(true)) |
| | | } |
| | | // 2.3 é¢éæ å¢é pull æ¥å£ï¼ç»§ç»èµ° list |
| | | if (hasChannelRows) { |
| | | void channelStore.fetchChannelList().catch((error) => console.warn('[IM] åå°å·é¢éå表失败', error)) |
| | | } else { |
| | | requiredFetches.push(channelStore.fetchChannelList()) |
| | | } |
| | | // 2.4 æ§è¡å è½½ |
| | | if (requiredFetches.length > 0) { |
| | | await Promise.all(requiredFetches) |
| | | } |
| | | |
| | | // 2.5 好åç³è¯·å¢éè¡¥å¿ï¼é¦ç»ä¹è¦è·ï¼ç¦»çº¿æé´å¥½åç³è¯·åæ´ä¸ä¼å½±å好å主表 |
| | | void friendStore |
| | | .pullFriendRequests() |
| | | .catch((error) => console.warn('[IM] åå°å¢éæå¥½åç³è¯·å¤±è´¥', error)) |
| | | |
| | | // 3. ä¼è¯è¯»ä½ç½®å
è¡¥å¿ï¼æ¶æ¯å
¥åºæ¶å¯ç´æ¥è¿æ»¤å·²è¯»å岿¶æ¯ |
| | | await conversationStore |
| | | .pullConversationReads() |
| | | .catch((error) => console.warn('[IM] æåä¼è¯è¯»ä½ç½®å¤±è´¥', error)) |
| | | |
| | | // 4. 宿¶éä¿¡ï¼å»º WebSocket é¿è¿æ¥ + æç¦»çº¿æ¶æ¯ï¼pullOnce finally æ loading å½ä½ï¼ |
| | | webSocketStore.connect() |
| | | await pullOnce() |
| | | |
| | | // 5. é»è®¤éä¸ç¬¬ä¸ä¸ªä¼è¯ï¼è¥ç½®é¡¶åç»å¤äºæå æï¼éè·³è¿è¢«æå éèç置顶项ï¼é¿å
èªå¨å±å¼æå |
| | | const sorted = conversationStore.getSortedConversationList |
| | | const firstVisible = pickFirstVisibleConversation(sorted) |
| | | if (firstVisible && !conversationStore.activeConversation) { |
| | | conversationStore.setActiveConversation(firstVisible) |
| | | } |
| | | } catch (error) { |
| | | // 1. é¦æå¤±è´¥ï¼æå¨å¤ä½ loadingï¼pullOnce 没è·å°ï¼å®ç finally å
ä¸å°è¿éï¼ï¼å¦ååç»ä¼è¯å表åå
¥å
¨è¢«æ© return 黿 |
| | | // 2. WebSocket ä¸å¨è¿é disconnectââè·¯ç±ç¦»å¼ä¼èµ° onUnmounted èªç¶æ¸
çï¼ç¨æ·ä¹å¯ä»¥å·æ°éè¯ |
| | | conversationStore.loading = false |
| | | console.error('[IM] åå§å失败', error) |
| | | } |
| | | }) |
| | | |
| | | /** |
| | | * éé¦å±èªå¨æ¿æ´»çä¼è¯ï¼ç½®é¡¶åç»æå æ¶è·³è¿è¢«æå éèç置顶项ï¼é¿å
æ¿æ´»å被èªå¨é¡¶ä¸æ¥ |
| | | * |
| | | * æå æå¤å®åªçç¨æ·å¼å
³ï¼é置顶 / ææªè¯»ç置顶项å§ç»å¯è§ï¼å
¨æ¯å¯æå 置顶æ¶åéå° sorted[0] å
åº |
| | | */ |
| | | function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | undefined { |
| | | if (sorted.length === 0) { |
| | | return undefined |
| | | } |
| | | const pinnedExpanded = |
| | | localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true' |
| | | if (pinnedExpanded) { |
| | | return sorted[0] |
| | | } |
| | | return sorted.find((c) => !c.top || (!c.silent && (c.unreadCount || 0) > 0)) ?? sorted[0] |
| | | } |
| | | |
| | | /** æ ç¾å
³éå flush è稿éåï¼debounce é»è®¤ trail-edge 触åï¼æå䏿¬¡è¾å
¥å¯è½è¿åå¨éåé */ |
| | | function onBeforeUnload() { |
| | | conversationStore.flushConversationDraftSave() |
| | | } |
| | | window.addEventListener('beforeunload', onBeforeUnload) |
| | | |
| | | /** ç¦»å¼ IM 主壳ï¼åæ¶ pullãæå¼ WebSocketãä¿åè稿ã忢è¯é³ãè§£ç» unloadï¼å¹¶ç»æå½å IM session */ |
| | | onUnmounted(() => { |
| | | cancelPull() |
| | | webSocketStore.disconnect() |
| | | conversationStore.flushConversationDraftSave() |
| | | faceStore.clear() |
| | | // 模å级åä¾ audio ä¸ä¼éè§å¾å¸è½½èªå¨åï¼ä¸»å¨åæé¿å
åè·¯ç±åè¯é³ç»§ç»å |
| | | voicePlayer.stop() |
| | | window.removeEventListener('beforeunload', onBeforeUnload) |
| | | // 忢å½å IM session å¹¶æ¸
çå store å
å |
| | | void stopRequests() |
| | | }) |
| | | |
| | | /** |
| | | * å½åä¼è¯åæ¢ï¼æ¬å°æ¸
é¶æªè¯» + 䏿¥å端已读 + ç§èè¡¥"对æ¹å·²è¯»å°åªæ¡" |
| | | * |
| | | * type+targetId ä¸èµ·çå¬ï¼ç§èä¸ç¾¤è id åå·æ¶åæ¢ä¹è½è§¦åï¼å
¶å®ä¼è¯å·²è¯»ç¶æç± WebSocket |
| | | * READ / RECEIPT äºä»¶è¢«å¨åæ¥ãç§èè¡¥ä¸æ¬¡æå¯¹æ¹å·²è¯»ä½ç½®ï¼å¼¥è¡¥ç¦»çº¿ / å¤ç«¯æ¼æç RECEIPT æ¨é |
| | | */ |
| | | watch( |
| | | () => [ |
| | | conversationStore.activeConversation?.type, |
| | | conversationStore.activeConversation?.targetId |
| | | ], |
| | | async ([type, targetId]) => { |
| | | if (!targetId) { |
| | | return |
| | | } |
| | | // æ¬å°æ¸
é¶æªè¯» + 䏿¥å端已读ï¼è®©å
¶å®ç«¯ / å¯¹æ¹ UI 忥 |
| | | await readActive() |
| | | // ç§èè¡¥ä¸æ¬¡"对æ¹å·²è¯»å°åªæ¡"ï¼å¼¥è¡¥ç¦»çº¿ / å¤ç«¯æ¼æç RECEIPT æ¨é |
| | | if (type === ImConversationType.PRIVATE) { |
| | | void syncPrivateReadStatus(targetId) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | /** |
| | | * æµè§å¨æ ç¾ title æ¼ä¸æªè¯»æ°åç¼ï¼ä¾ï¼(63æ¡æªè¯»)èéæºç |
| | | * |
| | | * è·¯ç±åæ¢æ¶ router.afterEach ä¼è° useTitle éç½® titleï¼ç¨ nextTick æå¨å®ä¹ååè¦ç |
| | | * ä¸å¹¶çå¬ route.fullPathï¼IM åè·¯ç±åæ¢ï¼æ¶æ¯ / é讯å½ï¼ä¹è½éæ°å ä¸åç¼ |
| | | */ |
| | | watch( |
| | | [() => conversationStore.getTotalUnreadCount, () => route.fullPath], |
| | | ([count]) => { |
| | | nextTick(() => { |
| | | const base = preferences.app.name |
| | | document.title = count > 0 ? `(${count > 99 ? '99+' : count}æ¡æªè¯»)${base}` : base |
| | | }) |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | IM å¤å±å®¹å¨ï¼è天模åçå
¨å±æ²æµ¸å¼å£³ |
| | | - 左侧 ToolBarï¼å¤´å + ä¸ Tabï¼æ¶æ¯/好å/群èï¼+ åºé¨è®¾ç½® |
| | | - å³ä¾§ <router-view>ï¼æè·¯ç±æ¸²æ MessagePage / FriendPage / GroupPage |
| | | - æè½½å
¨å±å¼¹å±ï¼UserInfoCard / GroupInfoCard / ContextMenu |
| | | --> |
| | | <div |
| | | class="im-home flex w-full h-full overflow-hidden bg-[var(--ant-color-bg-layout)] text-[var(--ant-color-text)]" |
| | | > |
| | | <ToolBar /> |
| | | <!-- |
| | | keep-alive ç¼åå页é¢ï¼ |
| | | - å Tab ä¸é建ç»ä»¶ï¼MessagePanel æ»å¨ä½ç½®ãè¾å
¥æ¡è稿ç UI ç¶æä¸ä¸¢ |
| | | - Vue 3 é keep-alive ä¸è½ç´æ¥å
<router-view>ï¼ä¼æè¦åï¼ï¼å¿
须走 v-slot æ¿ Component |
| | | --> |
| | | <router-view v-if="childRouteReady" v-slot="{ Component }"> |
| | | <keep-alive> |
| | | <component :is="Component" /> |
| | | </keep-alive> |
| | | </router-view> |
| | | <div v-else class="flex-1 bg-[var(--ant-color-bg-container)]"></div> |
| | | |
| | | <!-- å
¨å±å¼¹å±ï¼ç± useImUiStore ç»ä¸è°åº¦ --> |
| | | <UserInfoCard /> |
| | | <GroupInfoCard /> |
| | | <ContextMenu /> |
| | | |
| | | <!-- 宿¶éè¯æµ®å±ï¼çå¬ rtcStore å
¨å±ç¶æï¼å¯å¨ä»»æ IM åé¡µå¼¹åº --> |
| | | <RtcCallContainer /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | :global(:root) { |
| | | --im-border-color-lighter: #e8eaed; |
| | | --im-resize-line-color: #d8dde5; |
| | | |
| | | /* |
| | | * antd 4.x ä¸è¾åº --ant-color-* å
¨å± CSS åéï¼ä»
React antd ç theme.cssVar æ¯æï¼ï¼ |
| | | * è IM è天端大éç´æ¥ä½¿ç¨ var(--ant-color-*)ãæ¤å¤ç¨ Vben design token å
åºå®ä¹ï¼ |
| | | * æ å°è§åä¸ useAntdDesignTokens åç» antd ç token å®å
¨ä¸è´ï¼é主é¢ï¼äº®/æï¼èªå¨åæ¢ã |
| | | * 缺失æ¶è¿äºåé为空 â èæ¯éæãè¾¹æ¡/å¡«å
失æï¼å
¸åï¼è¡¨æ
颿¿éåºè¾å
¥æ¡å 使åï¼ã |
| | | */ |
| | | --ant-color-bg-container: hsl(var(--card)); |
| | | --ant-color-bg-elevated: hsl(var(--popover)); |
| | | --ant-color-bg-layout: hsl(var(--background-deep)); |
| | | --ant-color-border: hsl(var(--border)); |
| | | --ant-color-border-secondary: hsl(var(--border)); |
| | | --ant-color-text: hsl(var(--foreground)); |
| | | --ant-color-text-secondary: hsl(var(--foreground) / 65%); |
| | | --ant-color-text-placeholder: hsl(var(--foreground) / 45%); |
| | | --ant-color-text-disabled: hsl(var(--foreground) / 30%); |
| | | /* |
| | | * fill ç³»åï¼æµ
è²ä¸ç¨ Element Plus 飿 ¼çãå·è°æµ
ç°å®è²ãèéåéææ·±è²ï¼ |
| | | * å¦å颿¿ï¼ä¼è¯å表 / æ¶æ¯é¢æ¿çï¼å å¨ç°åºä¸ä¼æ¾èåæï¼å Vue3+EP çå¹²åç½å·®è·ææ¾ã |
| | | * åå¼ä¾ç»ä»¶å®é
ç¨æ³ï¼secondary å¤ç¨äºé¢æ¿åºï¼åææµ
ï¼å¯¹é½ EP --el-bg-color-page #f5f7faï¼ï¼ |
| | | * tertiary å¤ç¨äº hover / 徿 åï¼æ¯é¢æ¿ç¥æ·±ï¼ä¿è¯ hover å¯è§ï¼ãæ·±è²å¨ .dark éå¦è¡è¦çã |
| | | */ |
| | | --ant-color-fill: #e2e6ec; |
| | | --ant-color-fill-secondary: #f4f6f9; |
| | | --ant-color-fill-tertiary: #eaedf2; |
| | | --ant-color-fill-dark: #d5dae2; |
| | | --ant-color-primary: hsl(var(--primary)); |
| | | --ant-color-primary-hover: hsl(var(--primary) / 80%); |
| | | --ant-color-primary-bg: hsl(var(--primary) / 12%); |
| | | --ant-color-primary-bg-hover: hsl(var(--primary) / 18%); |
| | | --ant-color-info: hsl(var(--primary)); |
| | | --ant-color-success: hsl(var(--success)); |
| | | --ant-color-success-bg: hsl(var(--success) / 12%); |
| | | --ant-color-success-bg-hover: hsl(var(--success) / 18%); |
| | | --ant-color-warning: hsl(var(--warning)); |
| | | --ant-color-warning-bg: hsl(var(--warning) / 12%); |
| | | --ant-color-warning-bg-hover: hsl(var(--warning) / 18%); |
| | | --ant-color-error: hsl(var(--destructive)); |
| | | } |
| | | |
| | | :global(.dark) { |
| | | --im-border-color-lighter: rgb(255 255 255 / 12%); |
| | | --im-resize-line-color: rgb(255 255 255 / 18%); |
| | | |
| | | /* æ·±è²ä¸ fill åå°ãåºäºåæ¯è²çåéæäº®è²ãï¼å 卿·±åºä¸å¾å°èªç¶çå¾®äº®è¡¨é¢ */ |
| | | --ant-color-fill: hsl(var(--foreground) / 12%); |
| | | --ant-color-fill-secondary: hsl(var(--foreground) / 8%); |
| | | --ant-color-fill-tertiary: hsl(var(--foreground) / 4%); |
| | | --ant-color-fill-dark: hsl(var(--foreground) / 18%); |
| | | } |
| | | </style> |