| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation } from '../../types' |
| | | |
| | | import { computed, nextTick, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Dropdown, Input, Menu } from 'ant-design-vue' |
| | | |
| | | import { ImConversationType } from '../../../utils/constants' |
| | | import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation' |
| | | import { StorageKeys } from '../../../utils/db' |
| | | import { getGroupDisplayName } from '../../../utils/user' |
| | | import { ResizableAside } from '../../components' |
| | | import { FriendAddDialog } from '../../components/friend' |
| | | import { GroupCreateDialog } from '../../components/group' |
| | | import { useConversationStore } from '../../store/conversationStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { useImUiStore } from '../../store/uiStore' |
| | | import { ConversationItem, MessagePanel } from './components' |
| | | |
| | | defineOptions({ name: 'ImMessagePage' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const groupStore = useGroupStore() |
| | | const uiStore = useImUiStore() |
| | | |
| | | // ==================== ä¼è¯å表 ==================== |
| | | |
| | | const keyword = ref('') |
| | | |
| | | const sortedConversations = computed(() => conversationStore.getSortedConversationList) |
| | | |
| | | /** é¡¶é¨æç´¢æ¡è¿æ»¤ä¼è¯ï¼åªæ name 模ç³å¹é
ï¼é¿å
å½ä¸ lastContent çæ¬¡è¦åæ®µå¹²æ° */ |
| | | const filteredConversations = computed(() => |
| | | filterConversationsByKeyword(sortedConversations.value, keyword.value) |
| | | ) |
| | | |
| | | // ==================== 置顶ç¸å
³ ==================== |
| | | |
| | | const PINNED_FOLD_THRESHOLD = 3 // 置顶è¶
è¿è¯¥æ°éæ¶æ¾ç¤ºæå å
¥å£ï¼ä»¥ä¸æ°éç´æ¥éºå¼ï¼é¿å
åæ¡ç½®é¡¶å°±åºæå 头è§è§å¤ªéï¼ |
| | | |
| | | // 置顶æå å±å¼æï¼localStorage æä¹
åï¼å·æ°åä¿çç¨æ·ä¸æ¬¡çéæ©ï¼å¯¹é½å¾®ä¿¡ |
| | | const pinnedExpanded = ref( |
| | | localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true' |
| | | ) |
| | | |
| | | /** toggle + åç */ |
| | | function togglePinnedExpanded() { |
| | | pinnedExpanded.value = !pinnedExpanded.value |
| | | localStorage.setItem( |
| | | StorageKeys.localStorage.conversationPinnedExpanded, |
| | | String(pinnedExpanded.value) |
| | | ) |
| | | } |
| | | |
| | | /** 置顶ä¼è¯ï¼åç¬åçï¼ç»æå å¤´è®¡æ° + æå åºæ¸²æç¨ */ |
| | | const pinnedConversations = computed(() => filteredConversations.value.filter((c) => c.top)) |
| | | |
| | | /** é置顶ä¼è¯ï¼æå æä¸å§ç»éºå¼å¨æå 头ä¹ä¸ */ |
| | | const normalConversations = computed(() => filteredConversations.value.filter((c) => !c.top)) |
| | | |
| | | /** |
| | | * 置顶å两å ï¼visibleï¼æå 头ä¹ä¸ = æªè¯» + å½åæ¿æ´»ï¼/ foldableï¼æå 头ä¹ä¸ï¼ï¼ä¸æ¬¡ partition 宿 |
| | | * |
| | | * å½åæ¿æ´»ä¼è¯ä¹"é"å¨ visibleï¼é¿å
ç¹å¼æªè¯»ç½®é¡¶ â ç«å»è¢«è¯» â å表ä¸éªéæåæå çä½éª |
| | | */ |
| | | const pinnedGroups = computed(() => { |
| | | const visible: Conversation[] = [] |
| | | const foldable: Conversation[] = [] |
| | | for (const conversation of pinnedConversations.value) { |
| | | if (isActiveConversation(conversation) || hasUnreadBadge(conversation)) { |
| | | visible.push(conversation) |
| | | } else { |
| | | foldable.push(conversation) |
| | | } |
| | | } |
| | | return { visible, foldable } |
| | | }) |
| | | |
| | | /** æå æ¶åªæ¸² visibleï¼æªè¯» / æ¿æ´»ç©¿éï¼ï¼å±å¼æ¶æ¸²å
¨é¨ ââ å±å¼åä¸åç»ï¼é¿å
ç¹å»æå åºè·¨ç»ä¸è·³ */ |
| | | const renderedPinnedConversations = computed(() => |
| | | pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible |
| | | ) |
| | | |
| | | /** 置顶æå æ¶æ¯å¦ä¸æµ®å°æå 头ä¹ä¸ï¼ä»
以æ°åå¾½æ 为åï¼å
ææ°å³ä¾¿ææªè¯»ä¹åªå±ç¤ºå°çº¢ç¹ï¼ä¸åä¸ä¸æµ® */ |
| | | function hasUnreadBadge(conversation: Conversation): boolean { |
| | | return !conversation.silent && (conversation.unreadCount || 0) > 0 |
| | | } |
| | | |
| | | /** æ¯å¦ä¸ºå½åæ¿æ´»ä¼è¯ */ |
| | | function isActiveConversation(conversation: Conversation): boolean { |
| | | const active = conversationStore.activeConversation |
| | | return !!active && getConversationKey(active) === getConversationKey(conversation) |
| | | } |
| | | |
| | | /** |
| | | * æ¯å¦è¿å
¥"åç»æ¨¡å¼"ï¼ç¬ç«æµ
åº + å¯è½ææå 头ï¼ï¼æç´¢æ¶ä¸åç»ï¼ç¨æ·å¨æ¾äººï¼å«å让æå æ¡ä½ï¼ï¼ |
| | | * ç½®é¡¶æ° < éå¼ä¹ä¸åç»ï¼é¿å
åæ¡ç½®é¡¶å°±åºæå 头è§è§å¤ªé |
| | | */ |
| | | const showPinnedSection = computed( |
| | | () => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD |
| | | ) |
| | | |
| | | // ==================== æ·»å æå ==================== |
| | | |
| | | const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // æ·»å æåå¼¹çª refï¼å³ä¸è§ +-ä¸æãæ·»å æåãå
¥å£è° open() 触å |
| | | |
| | | // ==================== 建群ç¸å
³ ==================== |
| | | |
| | | const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // å起群èå¼¹çª refï¼handleOpenCreateGroup è° open() æå¼ |
| | | |
| | | /** æå¼å起群èå¼¹çªï¼æ éå®é¡¹çå
¨å±å
¥å£ */ |
| | | function handleOpenCreateGroup() { |
| | | createGroupDialogRef.value?.open() |
| | | } |
| | | |
| | | /** å¤ç建群æå */ |
| | | function handleGroupCreated(groupId: number) { |
| | | // GroupCreateDialog å·²ç» upsertGroup ææ°ç¾¤åè¿ storeï¼è¿éåª get + æå¼ä¼è¯ |
| | | const group = groupStore.getGroup(groupId) |
| | | if (!group) { |
| | | return |
| | | } |
| | | conversationStore.openConversation( |
| | | groupId, |
| | | ImConversationType.GROUP, |
| | | getGroupDisplayName(group), |
| | | group.avatar || '', |
| | | { silent: !!group.silent } |
| | | ) |
| | | } |
| | | |
| | | // ==================== æ»å¨å°ä¸ä¸ä¸ªæªè¯» ==================== |
| | | // å·¥å
·æ ãæ¶æ¯ãTab äºæ¬¡ç¹å» â uiStore.nextUnreadJumpNonce éå¢ â æ¬é¡µ watch æ»å¨å°ä¸ä¸æ¡æªè¯» |
| | | // å«å
ææ°ä¼è¯ï¼å°çº¢ç¹ä¹ç®æªè¯»ï¼ï¼éè¿ç»´æ¤ lastJumpedConversationKey 让è¿ç»ç¹å»é¡ºåºç©¿è¿æ´ä¸ªæªè¯»å表 |
| | | |
| | | /** 䏿¬¡å½ä¸çæªè¯»ä¼è¯ keyï¼ä¸ºç©ºæ¶ä»æªè¯»å表头å¼å§ */ |
| | | let lastJumpedConversationKey: null | string = null |
| | | |
| | | /** æ»å¨å°ä¸ä¸ä¸ªæªè¯»ä¼è¯ï¼å«å
ææ°ï¼ï¼ç®æ 被æç´¢æ¡è¿æ»¤æèå¨ç½®é¡¶æå åºæ¶å
è§£é¤æ¦æªåæ» */ |
| | | async function jumpToNextUnread() { |
| | | // å«å
ææ°çå
¨éæªè¯»ä¼è¯ï¼ç©ºåç´æ¥è¿å |
| | | const unreadList = sortedConversations.value.filter((c) => (c.unreadCount || 0) > 0) |
| | | if (unreadList.length === 0) { |
| | | return |
| | | } |
| | | // ä»ä¸æ¬¡å½ä¸é£æ¡å¾åæ¨ä¸ä½ï¼é¦æ¬¡æä¸æ¬¡ç®æ 已读å®ä¸å¨åè¡¨éæ¶ï¼refIndex=-1ï¼ä»å¤´å¼å§ |
| | | const refIndex = lastJumpedConversationKey |
| | | ? unreadList.findIndex((c) => getConversationKey(c) === lastJumpedConversationKey) |
| | | : -1 |
| | | const target = unreadList[(refIndex + 1) % unreadList.length] |
| | | if (!target) { |
| | | return |
| | | } |
| | | const key = getConversationKey(target) |
| | | lastJumpedConversationKey = key |
| | | |
| | | // ç®æ 被æç´¢å
³é®åè¿æ»¤æï¼æ¸
空 keyword 让å®éæ°è¿å
¥å¯è§å表 |
| | | if (keyword.value && !filteredConversations.value.some((c) => getConversationKey(c) === key)) { |
| | | keyword.value = '' |
| | | } |
| | | // ç®æ èå¨ç½®é¡¶æå åºï¼ä¸´æ¶å±å¼ï¼ä¸å localStorageï¼å·æ°åè¿åæå æ |
| | | const inFoldable = pinnedGroups.value.foldable.some((c) => getConversationKey(c) === key) |
| | | if (inFoldable && !pinnedExpanded.value) { |
| | | pinnedExpanded.value = true |
| | | } |
| | | |
| | | // ç keyword / pinnedExpanded ååååºå° DOM ååå»åç®æ å
ç´ |
| | | await nextTick() |
| | | const el = document.querySelector(`[data-conversation-key="${key}"]`) as HTMLElement | null |
| | | if (!el) { |
| | | return |
| | | } |
| | | // block: 'start' æç®æ ä¼è¯é¡¶å°å表å¯è§åºç¬¬ä¸è¡ï¼å¯¹é½å¾®ä¿¡"åå°ä¸ä¸ä¸ªæªè¯»=å表çç¬¬ä¸æ¡" |
| | | el.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
| | | } |
| | | |
| | | watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- æ¶æ¯ Tabï¼å·¦ä¾§ä¼è¯å表 + å³ä¾§è天颿¿ --> |
| | | <div class="flex flex-1 min-w-0 h-full"> |
| | | <!-- 左侧ä¼è¯å表ï¼å¯ææ½å®½åº¦ï¼ --> |
| | | <ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth"> |
| | | <!-- é¡¶é¨ï¼æç´¢æ¡ + "+" å·ä¸æï¼å¯¹é½å¾®ä¿¡ PCï¼å起群è / æ·»å æåï¼ï¼h-14 ä¸å³ä¾§ MessagePanel 头é¨å¯¹é½ --> |
| | | <div |
| | | class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <Input v-model:value="keyword" placeholder="æç´¢" allow-clear class="flex-1"> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | <Dropdown :trigger="['click']" placement="bottom"> |
| | | <Button size="small" shape="circle"> |
| | | <Icon icon="ant-design:plus-outlined" /> |
| | | </Button> |
| | | <template #overlay> |
| | | <Menu> |
| | | <Menu.Item @click="handleOpenCreateGroup"> |
| | | <span class="inline-flex items-center gap-2"> |
| | | <Icon icon="ant-design:message-outlined" :size="16" /> |
| | | <span>å起群è</span> |
| | | </span> |
| | | </Menu.Item> |
| | | <Menu.Item @click="friendAddDialogRef?.open()"> |
| | | <span class="inline-flex items-center gap-2"> |
| | | <Icon icon="ant-design:user-add-outlined" :size="16" /> |
| | | <span>æ·»å æå</span> |
| | | </span> |
| | | </Menu.Item> |
| | | </Menu> |
| | | </template> |
| | | </Dropdown> |
| | | </div> |
| | | |
| | | <!-- ä¼è¯åè¡¨ä¸»ä½ --> |
| | | <div class="flex-1 overflow-y-auto"> |
| | | <!-- 置顶ä¼è¯ï¼æ°é < é弿æç´¢ä¸ç´æ¥éºå¼ï¼å¦åè¿å
¥åç»æ¨¡å¼ï¼ç¬ç«æµ
åº + å¯éæå å¤´ï¼ --> |
| | | <template v-if="!showPinnedSection"> |
| | | <ConversationItem |
| | | v-for="conversation in pinnedConversations" |
| | | :key="getConversationKey(conversation)" |
| | | :conversation="conversation" |
| | | /> |
| | | </template> |
| | | <div v-else class="bg-[var(--ant-color-fill-secondary)]"> |
| | | <ConversationItem |
| | | v-for="conversation in renderedPinnedConversations" |
| | | :key="getConversationKey(conversation)" |
| | | :conversation="conversation" |
| | | /> |
| | | |
| | | <!-- æå å¤´ï¼æ¾å¨ç½®é¡¶åºåºé¨å¯¹é½ WeChat macï¼å±å¼ / æå æå
±ç¨ï¼ä»
å¨è¿æ"坿å "å
容ãæå½åå·²å±å¼æ¶åºç° --> |
| | | <div |
| | | v-if="pinnedGroups.foldable.length > 0 || pinnedExpanded" |
| | | class="flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors text-13px text-[var(--ant-color-text)] border-y border-y-solid border-[var(--im-border-color-lighter)] hover:bg-[var(--ant-color-fill)]" |
| | | @click="togglePinnedExpanded" |
| | | > |
| | | <span class="flex items-center gap-1.5"> |
| | | <Icon icon="ant-design:menu-outlined" :size="14" /> |
| | | {{ |
| | | pinnedExpanded |
| | | ? 'æå 置顶è天' |
| | | : `${pinnedGroups.foldable.length} 个置顶è天` |
| | | }} |
| | | </span> |
| | | <Icon |
| | | :icon="pinnedExpanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- æ®éä¼è¯ --> |
| | | <ConversationItem |
| | | v-for="conversation in normalConversations" |
| | | :key="getConversationKey(conversation)" |
| | | :conversation="conversation" |
| | | /> |
| | | |
| | | <div |
| | | v-if="filteredConversations.length === 0" |
| | | class="flex items-center justify-center py-10 text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | {{ keyword ? 'æ²¡ææ»¡è¶³æ¡ä»¶çä¼è¯' : 'ææ ä¼è¯' }} |
| | | </div> |
| | | </div> |
| | | </ResizableAside> |
| | | |
| | | <!-- å³ä¾§è天颿¿ --> |
| | | <MessagePanel /> |
| | | |
| | | <!-- æ·»å æå / å起群èå¼¹çª --> |
| | | <FriendAddDialog ref="friendAddDialogRef" /> |
| | | <GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" /> |
| | | </div> |
| | | </template> |