| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../../../../components/group' |
| | | import type { GroupLite } from '../../../../types' |
| | | |
| | | import { computed, nextTick, provide, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { message, Popover, Tooltip } from 'ant-design-vue' |
| | | |
| | | import { createCall } from '#/api/im/rtc' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { ImConversationType, ImRtcCallMediaType, ImRtcCallStatus } from '#/views/im/utils/constants' |
| | | import { getClientConversationId } from '#/views/im/utils/db' |
| | | import { resolveCallEndReasonText } from '#/views/im/utils/message' |
| | | import { getGroupDisplayName, getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user' |
| | | |
| | | import { GroupMuteMemberDialog } from '../../../../components/group' |
| | | import { |
| | | RtcCallMemberPickerDialog, |
| | | RtcGroupCallBanner |
| | | } from '../../../../components/rtc' |
| | | import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect' |
| | | import { useVoicePlayer } from '../../../../composables/useVoicePlayer' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useFriendStore } from '../../../../store/friendStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | import { useRtcStore } from '../../../../store/rtcStore' |
| | | import { useImUiStore } from '../../../../store/uiStore' |
| | | import { ConversationGroupSide } from '../conversation' |
| | | import { ConversationPrivateSide } from '../conversation' |
| | | import { MessageInput } from '../input' |
| | | import { MessageMultiSelectBar } from '../input' |
| | | import { MessageForwardDialog } from './forward' |
| | | import { MessageMergeDetailDialog } from './forward' |
| | | import { |
| | | IM_FORWARD_DIALOG_KEY, |
| | | IM_MERGE_DETAIL_DIALOG_KEY, |
| | | IM_RTC_REDIAL_KEY |
| | | } from './forward/keys' |
| | | import GroupPinnedMessage from './group-pinned-message.vue' |
| | | import GroupRequestPending from './group-request-pending.vue' |
| | | import MessageHistory from './message-history.vue' |
| | | import MessageItem from './message-item.vue' |
| | | |
| | | defineOptions({ name: 'ImMessagePanel' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const friendStore = useFriendStore() |
| | | const uiStore = useImUiStore() |
| | | const groupStore = useGroupStore() |
| | | const rtcStore = useRtcStore() |
| | | const listRef = ref<HTMLElement>() |
| | | |
| | | // ==================== 转å / åå¹¶æ¶æ¯è¯¦æ
ï¼æ¬å° dialog æµ®å± ==================== |
| | | // MessageItem / MessageMultiSelectBar / MessageHistory éè¿ inject 触åï¼ä¸æå
¨å± store |
| | | |
| | | const forwardDialogRef = ref<InstanceType<typeof MessageForwardDialog>>() |
| | | const mergeDetailDialogRef = ref<InstanceType<typeof MessageMergeDetailDialog>>() |
| | | |
| | | provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts)) |
| | | provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.open(content)) |
| | | provide(IM_RTC_REDIAL_KEY, (mediaType: number) => { |
| | | if (isPrivate.value) { |
| | | void startPrivateCall(mediaType) |
| | | } |
| | | }) // ç§è RTC_CALL_END æ°æ³¡ç¹å»éæ¨ï¼MessageItem 注å
¥åè°ç¨ |
| | | |
| | | // ==================== å¤éæ¨¡å¼ ==================== |
| | | // 模å级åä¾ stateï¼composableï¼ï¼æ¬ç»ä»¶ä»
ååä¼è¯éåº + template æ¾éå¤å® |
| | | |
| | | const multiSelect = useMessageMultiSelect() |
| | | const voicePlayer = useVoicePlayer() |
| | | |
| | | /** åä¼è¯éåºå¤é + åè¯é³ï¼é¿å
ä¸ä¸ä¼è¯çå¾é / ææ¾ææ³æ¼å°æ°ä¼è¯ï¼type+targetId ä¸èµ·çå¬ï¼ç§èä¸ç¾¤è id åå·æ¶ä¹è½è§¦åï¼ */ |
| | | watch( |
| | | () => [ |
| | | conversationStore.activeConversation?.type, |
| | | conversationStore.activeConversation?.targetId |
| | | ], |
| | | () => { |
| | | multiSelect.exit() |
| | | voicePlayer.stop() |
| | | } |
| | | ) |
| | | |
| | | const messages = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return conversation |
| | | ? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId)) |
| | | : [] |
| | | }) |
| | | const isGroup = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.GROUP |
| | | ) |
| | | const isPrivate = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.PRIVATE |
| | | ) |
| | | const isChannel = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL |
| | | ) |
| | | |
| | | /** å½åæ¿æ´»ä¼è¯æ¯å¦åå²é群群ï¼ç¦ç¾¤éè¯ãéè群ç³è¯·æ¨ªå¹
çæä½å
¥å£ï¼è天åå²ã群å头åç
§å¸¸å±ç¤º */ |
| | | const isQuitGroup = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return ( |
| | | conversation?.type === ImConversationType.GROUP && |
| | | isGroupQuit(groupStore.getGroup(conversation.targetId)) |
| | | ) |
| | | }) |
| | | |
| | | /** ç§èä¼è¯ä¸å¯¹ç«¯ä¸æ¯ææå¥½åï¼æ¬ç«¯ friend è®°å½ç¼ºå¤±æ DISABLEï¼ï¼åè¾¹å é¤è¯ä¹ä¸ã被对æ¹å é¤ãä¸è§¦åæ¬ç«¯æ¨ªå¹
*/ |
| | | const showNotFriendBanner = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.PRIVATE) { |
| | | return false |
| | | } |
| | | return !friendStore.isActiveFriend(conversation.targetId) |
| | | }) |
| | | |
| | | /** ç¹å»ã对æ¹è¿ä¸æ¯ä½ çæåãè¶åï¼æå¼ UserInfoCardï¼å¼å¯¼ç¨æ·éæ°æ·»å */ |
| | | function handleNotFriendClick(event: MouseEvent) { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() |
| | | uiStore.openUserInfoCard( |
| | | { |
| | | id: conversation.targetId, |
| | | nickname: conversation.name, |
| | | avatar: conversation.avatar |
| | | }, |
| | | { x: rect.left, y: rect.bottom + 4 } |
| | | ) |
| | | } |
| | | |
| | | /** |
| | | * 群è header æ¾ç¤ºç人æ°ï¼ä¼å
groupStore.memberCountï¼æ éçæåå表ï¼ï¼æ å¼ååé members.length |
| | | * |
| | | * 乿以ä¸ç´æ¥ç¨ groupMembers.value.lengthï¼æååè¡¨æ¯æéæå è½½çï¼ååå°ç¾¤æ¶æªå è½½å®ï¼ |
| | | * è groupInfo.memberCount è·ç¾¤ä¿¡æ¯ä¸èµ·æ¥ï¼è½æ´æ©æ¾ç¤ºäººæ°é¿å
"å
空å蹦" |
| | | */ |
| | | const headerMemberCount = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return 0 |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return group?.memberCount ?? group?.members?.length ?? 0 |
| | | }) |
| | | |
| | | /** é¡¶é¨å¯æ é¢ï¼ä»
å½ç¾¤å¤æ³¨ â åç¾¤åæ¶æ¾ç¤ºå群åï¼å¯¹é½å¾®ä¿¡ PC åè¡ headerï¼ */ |
| | | const headerSubtitle = computed(() => { |
| | | const remark = groupInfo.value?.groupRemark |
| | | const name = groupInfo.value?.name |
| | | return remark && name && remark !== name ? name : '' |
| | | }) |
| | | |
| | | const BOTTOM_THRESHOLD = 80 // "æ¯å¦åçå¨åºé¨"çéå¼ï¼è·ç¦»åºé¨ < 80px è§ä¸ºåºé¨ |
| | | const showJumpToBottom = ref(false) // å½åæ¯å¦å·²ä¸å¨åºé¨ï¼æ¾ç¤º"åå°åºé¨"æé®ï¼ |
| | | const newMessageCount = ref(0) // ä¸å¨åºé¨æé´ç´¯è®¡çæ°æ¶æ¯æ° |
| | | |
| | | /** |
| | | * å½åæ¿æ´»ç群详æ
ï¼ä¼å
groupStoreï¼å¸¦è¯¦ç»å段ï¼ï¼æªå è½½å®æ¶ç¨ activeConversation å
åº |
| | | * |
| | | * groupStore æ¯æéæå è½½çï¼å次è¿ç¾¤æ¶ ensureGroupData 触ååæä¼æå®æ´æ°æ®ï¼ |
| | | * å
åºå段ï¼name / avatarï¼ä¿è¯ header ä¸ä¼"éªç©º"ï¼notice / ownerId / memberCount |
| | | * å¿
é¡»ç store 就使æå¼ï¼è¿äºåæ®µå¨ conversation éæ²¡æï¼ |
| | | */ |
| | | const groupInfo = computed< |
| | | | (GroupLite & { |
| | | groupRemark?: string |
| | | notice?: string |
| | | ownerId?: number |
| | | remarkNickName?: string |
| | | }) |
| | | | undefined |
| | | >(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return undefined |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | const selfMember = group?.members?.find((member) => member.userId === getCurrentUserId()) |
| | | const showGroupName = group ? getGroupDisplayName(group) : conversation.name |
| | | return { |
| | | id: conversation.targetId, |
| | | name: group?.name || conversation.name, |
| | | showGroupName, |
| | | showImage: group?.avatar || conversation.avatar, |
| | | notice: group?.notice, |
| | | remarkNickName: selfMember?.displayUserName, |
| | | groupRemark: group?.groupRemark, |
| | | ownerId: group?.ownerUserId, |
| | | memberCount: group?.memberCount, |
| | | joinApproval: group?.joinApproval |
| | | } |
| | | }) |
| | | |
| | | /** 群æåå表ï¼ç´æ¥å groupStore ç¼åï¼map æ GroupMemberLite ç»ä¸æ¸¸æ¶è´¹ï¼@-mention / é请çï¼ */ |
| | | const groupMembers = computed<GroupMemberLite[]>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return (group?.members || []).map((member) => { |
| | | // æ¾ç¤ºåèµ°ã好å夿³¨ > ç¾¤å¤æ³¨ > ç宿µç§°ãä¸çº§ï¼å¤´åèµ° nickname ä¿ç¨³å® |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status, |
| | | role: member.role |
| | | } |
| | | }) |
| | | }) |
| | | |
| | | /** 忢å°ç¾¤ä¼è¯æ¶åæ¥ç¾¤ä¿¡æ¯ + æå */ |
| | | async function ensureGroupData(groupId: number) { |
| | | // è¿ç¨æç¾¤ä¿¡æ¯ï¼ç¾¤å / å
¬å / 群主çå
æ°æ®ï¼ |
| | | await groupStore.fetchGroupInfo(groupId).catch((error) => { |
| | | console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error) |
| | | }) |
| | | if (isGroupQuit(groupStore.getGroup(groupId))) { |
| | | return |
| | | } |
| | | |
| | | // å
ä» IDB 忥å 载群æåï¼è®©é¦å¸§ç«å³åºæåå / 头å |
| | | await groupStore.loadGroupMemberList(groupId).catch((error) => { |
| | | console.warn('[IM MessagePanel] loadGroupMemberList 失败', { groupId }, error) |
| | | return null |
| | | }) |
| | | const group = groupStore.getGroup(groupId) |
| | | if (!group?.membersLoaded || group.membersExpired) { |
| | | groupStore.fetchGroupMemberList(groupId, true).catch((error) => { |
| | | console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error) |
| | | }) |
| | | } |
| | | } |
| | | |
| | | /** ç¾¤ä¿¡æ¯æ½å±éç¹"å·æ°"ï¼å¼ºæä¸æ¬¡ææ°ç¾¤å
æ°æ® + 群æå */ |
| | | function reloadGroupData() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return |
| | | } |
| | | groupStore.fetchGroupInfo(conversation.targetId, true) |
| | | groupStore.fetchGroupMemberList(conversation.targetId, true) |
| | | } |
| | | |
| | | const historyDialogRef = ref<InstanceType<typeof MessageHistory>>() // å岿¶æ¯æ½å± refï¼ãè天åå²ãicon / æ½å±ãæ¥æ¾è天å
容ãå
¥å£é½è° open() 触å |
| | | const sideVisible = ref(false) // ä¿¡æ¯æ½å±å¼å
³ï¼ç¾¤è / ç§èå
±ç¨ä¸ä¸ª ref |
| | | const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>() |
| | | const callMemberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>() |
| | | const pendingMediaType = ref<null | number>(null) // 群éè¯åèµ·ï¼æåéæ©å¼¹çªæå¼æé´ä¸´æ¶ææç mediaType |
| | | |
| | | /** æ¶æ¯å³é®èåãç¦è¨ãâ æå¼æ¶é¿éæ©å¼¹çª */ |
| | | function handleMuteMember(groupId: number, userId: number, displayName: string) { |
| | | muteMemberDialogRef.value?.open(groupId, userId, displayName) |
| | | } |
| | | |
| | | /** ä¿¡æ¯æ½å±ç toggleï¼è· header ä¸ 3 ç¹å¾æ æé®å
±ç¨ */ |
| | | function toggleSide() { |
| | | sideVisible.value = !sideVisible.value |
| | | } |
| | | |
| | | const callPopoverVisible = ref(false) // ç§èéè¯å
¥å£ï¼popover 触åï¼ç¹ è¯é³ / è§é¢ ç´æ¥åèµ· |
| | | const callInviting = ref(false) // éè¯åèµ·ä¸ |
| | | async function startPrivateCall(mediaType: number) { |
| | | callPopoverVisible.value = false |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | await doInvite({ |
| | | conversationType: ImConversationType.PRIVATE, |
| | | mediaType, |
| | | inviteeIds: [conversation.targetId] |
| | | }) |
| | | } |
| | | |
| | | /** 群éè¯å
¥å£ï¼é»è®¤è¯é³ç´æ¥å¼¹é人ï¼ä¸å¾®ä¿¡ç¾¤éè¯ä¸è´ï¼è¿éè¯åç¨æ·æé弿å头 */ |
| | | function handleGroupCall() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | pendingMediaType.value = ImRtcCallMediaType.VOICE |
| | | callMemberPickerRef.value?.open({ groupId: conversation.targetId, mode: 'invite' }) |
| | | } |
| | | |
| | | /** é人弹çªç¡®è®¤ï¼å¸¦éä¸ ID å起群éè¯ */ |
| | | async function onCallMemberPicked(selectedIds: number[]) { |
| | | const conversation = conversationStore.activeConversation |
| | | const mediaType = pendingMediaType.value |
| | | pendingMediaType.value = null |
| | | if (!conversation || mediaType == null || selectedIds.length === 0) { |
| | | return |
| | | } |
| | | await doInvite({ |
| | | conversationType: ImConversationType.GROUP, |
| | | mediaType, |
| | | groupId: conversation.targetId, |
| | | inviteeIds: selectedIds |
| | | }) |
| | | } |
| | | |
| | | /** å®é
è° create æ¥å£ï¼ç»ä¸å¤çæå / ENDEDï¼å¦å¿çº¿ç«å³ç»æï¼ */ |
| | | async function doInvite(reqVO: { |
| | | conversationType: number |
| | | groupId?: number |
| | | inviteeIds: number[] |
| | | mediaType: number |
| | | }) { |
| | | if (callInviting.value) { |
| | | return |
| | | } |
| | | if (rtcStore.isActive) { |
| | | message.warning('å½åå·²æéè¯') |
| | | return |
| | | } |
| | | callInviting.value = true |
| | | try { |
| | | const data = await createCall(reqVO) |
| | | // å端已 INSERT + ç«å³ endï¼å¦å¿çº¿ï¼ï¼toast æç¤ºï¼ä¸è¿ INVITING é¶æ®µï¼chat tip ç± RTC_CALL_END æ¨éåå
¥æ¶æ¯æµ |
| | | if (data.status === ImRtcCallStatus.ENDED) { |
| | | message.warning(resolveCallEndReasonText(data.endReason)) |
| | | return |
| | | } |
| | | // æ£å¸¸è¿å
¥ INVITING é¶æ®µï¼èµ° store é»è¾åèµ·éè¯ï¼åç»ç¶ææ´æ° / æ¶æ¯æµæ´æ°ç± RTC 模åç嬿¨éå¤ç |
| | | rtcStore.startInviting(data) |
| | | } finally { |
| | | callInviting.value = false |
| | | } |
| | | } |
| | | |
| | | /** å½åç§è对åºç好åï¼æ½å±å¤´é¨å±ç¤ºç¨ï¼ */ |
| | | const privateFriend = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.PRIVATE) { |
| | | return undefined |
| | | } |
| | | return friendStore.getFriend(conversation.targetId) |
| | | }) |
| | | |
| | | /** 计ç®è·ç¦»åºé¨çåç´ */ |
| | | function distanceFromBottom(): number { |
| | | const el = listRef.value |
| | | if (!el) { |
| | | return 0 |
| | | } |
| | | return el.scrollHeight - el.scrollTop - el.clientHeight |
| | | } |
| | | |
| | | /** |
| | | * æ¶æ¯å表æ»å¨äºä»¶ï¼å·æ°"æ¯å¦å¨åºé¨"ç¶æ |
| | | * - å¨åºé¨ï¼éè"åå°åºé¨"æµ®çª + æ¸
æ"æªè¯»æ°æ¶æ¯"è®¡æ° |
| | | * - ä¸å¨åºé¨ï¼æ¾ç¤º"åå°åºé¨"æµ®çªï¼æ°æ¶æ¯ä¼ç´¯è®¡å° newMessageCount |
| | | */ |
| | | function handleScroll() { |
| | | const dist = distanceFromBottom() |
| | | const atBottom = dist <= BOTTOM_THRESHOLD |
| | | showJumpToBottom.value = !atBottom |
| | | if (atBottom) { |
| | | newMessageCount.value = 0 |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * æ»å°åºé¨ï¼åä¼è¯ / æ¶å°æ°æ¶æ¯ï¼ä¸å½åå¨åºé¨ï¼/ ç¨æ·ä¸»å¨ç¹ãåå°åºé¨ã é½èµ°è¿é |
| | | * |
| | | * smooth=true èµ°å¹³æ»å¨ç»ï¼éåç¨æ·ä¸»å¨ç¹å»ï¼åå§ / èªå¨æ»å¨ç¨ autoï¼é¿å
ç¨æ·æç¥å°å¨ç»ææ½ |
| | | */ |
| | | async function scrollToBottom(smooth = false) { |
| | | // 1. æ»å°å½å scrollHeight çåºé¨ï¼å¾ç / è§é¢è¿å¨å è½½æ¶åªæ¯å¤§è´å°åºï¼ |
| | | // 1.1 ç v-for ææ°æ¶æ¯çæ£æ¸²æè¿ DOM ååç® scrollHeightï¼å¦åå·®æå䏿¡çä½ç½® |
| | | await nextTick() |
| | | if (!listRef.value) { |
| | | return |
| | | } |
| | | // 1.2 è§¦åæ»å¨ï¼smooth ä»
user 主å¨ç¹ãåå°åºé¨ãç¨ï¼åå§ / èªå¨æ»èµ° auto é¿å
å¨ç»ææ½æ |
| | | listRef.value.scrollTo({ |
| | | top: listRef.value.scrollHeight, |
| | | behavior: smooth ? 'smooth' : 'auto' |
| | | }) |
| | | newMessageCount.value = 0 |
| | | showJumpToBottom.value = false |
| | | // 1.3 è®°ä¸ãææåä¸ç scrollTopãï¼å¾ç / è§é¢å è½½å®åºé¨ä¼ä¸ç§»ï¼scrollTop 没å¨å°±è¯´æç¨æ·æ²¡æå¨æ»èµ° |
| | | // ä¸è½ç¨ distanceFromBottom 夿ï¼åºé¨ä¸ç§»ä¼è®© distance å大ï¼è¢«è¯¯å¤ä¸ºãç¨æ·æ»èµ°äºãç´æ¥æ¾å¼è¡¥æ» |
| | | const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight |
| | | |
| | | // 2. çåªä½å è½½å®åè¡¥æ»å°çå®åºé¨ |
| | | // 2.1 ç容å¨å
æªå è½½å®çå¾ç / è§é¢å
æ°æ®ï¼å è½½å®å scrollHeight ä¼å¢é¿å°çå®åºé¨ |
| | | await waitMediaSettled() |
| | | if (!listRef.value) { |
| | | return |
| | | } |
| | | // 2.2 ä»
å¨ç¨æ·æ²¡æå¨æ»èµ°æ¶ï¼scrollTop ä»è´´è¿ expectedScrollTopï¼æè¡¥æ»ï¼é¿å
çå¾
æé´ç¨æ·ä¸ç¿»è¢«ææ |
| | | if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) { |
| | | return |
| | | } |
| | | // 2.3 è¡¥æ»å°æ°åºé¨ |
| | | listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' }) |
| | | } |
| | | |
| | | /** |
| | | * çå¾
容å¨å
æªå è½½å®çå¾ç / è§é¢ï¼æå¤ç 2s 鲿¢è¶
å¤§èµæºææ´ä¸ªæ»å¨è·è¿å¡ä½ |
| | | * |
| | | * ä»
å
³å¿å
æ°æ®ï¼loadedmetadata / img.completeï¼ï¼ä¸ççæ£è§£ç ï¼å 为尺寸å¤ç® scrollHeight å°±è¡ |
| | | */ |
| | | function waitMediaSettled(): Promise<void> { |
| | | // 1. æ¶é容å¨å
æªå è½½å®çå¾ç / è§é¢ |
| | | if (!listRef.value) { |
| | | return Promise.resolve() |
| | | } |
| | | // 1.1 䏿¬¡æ« img + videoï¼æ element ç±»ååå«çãcomplete / readyStateãè¿æ»¤ pending |
| | | const pendingMedia = [...listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>('img, video')].filter((el) => (el instanceof HTMLImageElement ? !el.complete : el.readyState < 1)) |
| | | // 1.2 没æ pending ç´æ¥è¿åï¼çæ Promise.race / setTimeout éå
æé |
| | | if (pendingMedia.length === 0) { |
| | | return Promise.resolve() |
| | | } |
| | | |
| | | // 2. çææ pending èµæº load / errorï¼æé¿ 2s å
åº |
| | | // 2.1 æ¯ä¸ª element é½çå¬å¯¹åº loadedEvent + errorï¼ä»»ä¸è§¦åå³ resolveï¼ä¸è®©åæ¡å¤±è´¥å¡ raceï¼ |
| | | const loadAll = Promise.all( |
| | | pendingMedia.map( |
| | | (el) => |
| | | new Promise<void>((resolve) => { |
| | | const loadedEvent = el instanceof HTMLImageElement ? 'load' : 'loadedmetadata' |
| | | el.addEventListener(loadedEvent, () => resolve(), { once: true }) |
| | | el.addEventListener('error', () => resolve(), { once: true }) |
| | | }) |
| | | ) |
| | | ).then(() => undefined) |
| | | // 2.2 2s è¶
æ¶å
åºï¼é²æ¢è¶
å¤§èµæº / ç½ç»æèµ·ææ´ä¸ªæ»å¨è·è¿æ°¸ä¹
å¡ä½ |
| | | const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000)) |
| | | return Promise.race([loadAll, timeout]) |
| | | } |
| | | |
| | | /** |
| | | * å®ä½å°è天ä½ç½®ï¼MessageHistory è¡ä¸"å®ä½"æé® / æ°æ³¡å
å¼ç¨åç¹å»è§¦å |
| | | * |
| | | * 1. å
å
³æåå²å¼¹çªï¼é¿å
scroll æ¶é®æ¡ + dialog å
³éå让è天颿¿æ¿åç¦ç¹ï¼ |
| | | * 2. nextTick çå¼¹çª leave å¨ç» / å表渲æç¨³å®å忥 DOM |
| | | * 3. æ data-message-id æ¾ wrapperï¼scrollIntoView({ block: center }) è®©æ¶æ¯è½å°è§å£ä¸é¨ |
| | | * 4. å --highlight class çæé«äº®ï¼æç¤ºç¨æ·"å°±æ¯è¿æ¡" |
| | | * 5. æ¾ä¸å° wrapper(åæ¶æ¯å·²å页åºå»)æ¶å¼¹ warning æç¤º,ä¸å¾®ä¿¡"æ¶æ¯å·²ä¸å¨çªå£"è§æä¸è´ |
| | | */ |
| | | async function handleLocate(messageId: number) { |
| | | if (!messageId) { |
| | | return |
| | | } |
| | | await nextTick() |
| | | if (!listRef.value) { |
| | | return |
| | | } |
| | | const target = listRef.value.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`) |
| | | if (!target) { |
| | | message.warning('åæ¶æ¯ä¸å¨è§é') |
| | | return |
| | | } |
| | | target.scrollIntoView({ behavior: 'smooth', block: 'center' }) |
| | | target.classList.add('message-panel__message-anchor--highlight') |
| | | setTimeout(() => { |
| | | target.classList.remove('message-panel__message-anchor--highlight') |
| | | }, 1600) |
| | | } |
| | | |
| | | /** |
| | | * æ¶æ¯æ°éååæ¶çæ»å¨è·è¿çç¥ |
| | | * - å½åå¨åºé¨ â èªå¨æ»å°æ°åºé¨ï¼ç¨æ·å¨"宿¶è·è¯»"ï¼ä½éªä¸è·å¾®ä¿¡ä¸è´ï¼ |
| | | * - ä¸å¨åºé¨ â 累计 newMessageCountï¼è®© sticky æµ®çªæ¾ç¤º"X æ¡æ°æ¶æ¯"ï¼è®©ç¨æ·ä¸»å¨ç¹ |
| | | */ |
| | | watch( |
| | | () => messages.value.length, |
| | | (newLen, oldLen) => { |
| | | // ä»
å¤çæ°å¢ï¼delta > 0ï¼ï¼å é¤ / æ¤å让 length åå°æ¶ä¸å¨æ»å¨ç¶æ |
| | | const delta = (newLen || 0) - (oldLen || 0) |
| | | if (delta <= 0) { |
| | | return |
| | | } |
| | | // ç¨ BOTTOM_THRESHOLDï¼80pxï¼å容差ï¼ç¨æ·ç¨å¾®å¾ä¸ç¿»å è¡å°±è§ä½"ä¸å¨åºé¨"ï¼ |
| | | // å¦åä¸ç´ auto-scroll ä¼æäººæ£å¨è¯»çå
容顶走ï¼ä½éªç³ç³ |
| | | const dist = distanceFromBottom() |
| | | if (dist <= BOTTOM_THRESHOLD) { |
| | | scrollToBottom() |
| | | } else { |
| | | newMessageCount.value += delta |
| | | showJumpToBottom.value = true |
| | | } |
| | | } |
| | | ) |
| | | |
| | | /** |
| | | * 忢ä¼è¯ï¼æ¸
空"å¨ä¸å¨åºé¨"ç¸å
³ç¶æãå¼ºå¶æ»å°åºé¨ã群ä¼è¯é¢æèµæ |
| | | * |
| | | * type+targetId ä¸èµ·çå¬ï¼ç§èä¸ç¾¤è id åå·æ¶åæ¢ä¹è½è§¦åï¼immediate:true è®©é¦æ¬¡è¿å
¥é¡µé¢å°±è½åå§å |
| | | */ |
| | | watch( |
| | | () => [ |
| | | conversationStore.activeConversation?.type, |
| | | conversationStore.activeConversation?.targetId |
| | | ], |
| | | ([type, targetId]) => { |
| | | // åä¼è¯æ¶ä¸ä¸ä¼è¯çãæªè¯»ç´¯è®¡ + æµ®çªæ¾ç¤ºãå¿
é¡»æ¸
æï¼å¦åä¼å¸¦å°æ°ä¼è¯éçèµ·æ¥å¾çªå
|
| | | newMessageCount.value = 0 |
| | | showJumpToBottom.value = false |
| | | // æ½å±éå±ç¤ºçç¾¤ä¿¡æ¯ / 好åä¿¡æ¯å±äºä¸ä¸ä¼è¯ï¼åä¼è¯æ¶ç»ä¸å
³æ |
| | | sideVisible.value = false |
| | | scrollToBottom() |
| | | // ä»
群èé¢æè¯¦æ
/ æåï¼ç§è对端å¨é¦å± fetchFriendList æ¶å°±æäºï¼ |
| | | if (targetId && type === ImConversationType.GROUP) { |
| | | ensureGroupData(targetId) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="flex flex-1 flex-col min-w-0 bg-[var(--ant-color-fill-secondary)]"> |
| | | <template v-if="conversationStore.activeConversation"> |
| | | <!-- é¡¶é¨ headerï¼ç¬¬ä¸è¡ç¾¤å + å³ä¾§å¾æ ï¼ç¬¬äºè¡åµå
¥ç½®é¡¶æ°æ³¡ï¼ä»
群è + æç½®é¡¶ï¼ --> |
| | | <div |
| | | class="flex flex-shrink-0 flex-col bg-[var(--ant-color-fill-secondary)] border-b border-b-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <div class="flex items-center justify-between h-14 px-5"> |
| | | <span class="flex flex-col min-w-0"> |
| | | <span class="flex items-baseline gap-1.5 min-w-0"> |
| | | <span |
| | | class="overflow-hidden text-base font-medium truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ conversationStore.activeConversation?.name || '' }} |
| | | </span> |
| | | <span |
| | | v-if="isGroup && headerMemberCount > 0" |
| | | class="flex-shrink-0 text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | ({{ headerMemberCount }}) |
| | | </span> |
| | | </span> |
| | | <!-- 坿 é¢ï¼å¤æ³¨ â ç¾¤åæ¶å±ç¤ºå群åï¼æç¤ºç¨æ·å½åçå°ç䏻忝èªå·±è®¾ç夿³¨ --> |
| | | <span |
| | | v-if="headerSubtitle" |
| | | class="overflow-hidden text-xs truncate text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | {{ headerSubtitle }} |
| | | </span> |
| | | </span> |
| | | <div class="flex gap-2 items-center"> |
| | | <!-- è天åå² --> |
| | | <Tooltip title="è天åå²" placement="bottom"> |
| | | <Icon |
| | | icon="ep:chat-dot-round" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | @click="historyDialogRef?.open()" |
| | | /> |
| | | </Tooltip> |
| | | <!-- éè¯å
¥å£ï¼ç§èå¼¹ãè¯é³ / è§é¢ãpopoverï¼ç¾¤èç´æ¥è¿éäººå¼¹çª --> |
| | | <Popover |
| | | v-if="isPrivate" |
| | | v-model:open="callPopoverVisible" |
| | | placement="bottomRight" |
| | | :overlay-style="{ width: '140px' }" |
| | | trigger="click" |
| | | overlay-class-name="message-panel__call-popover" |
| | | > |
| | | <Icon |
| | | icon="ant-design:phone-outlined" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | /> |
| | | <template #content> |
| | | <div class="flex flex-col gap-0.5"> |
| | | <div |
| | | class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]" |
| | | @click="startPrivateCall(ImRtcCallMediaType.VOICE)" |
| | | > |
| | | <Icon icon="ant-design:phone-outlined" :size="16" /> |
| | | <span>è¯é³éè¯</span> |
| | | </div> |
| | | <div |
| | | class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]" |
| | | @click="startPrivateCall(ImRtcCallMediaType.VIDEO)" |
| | | > |
| | | <Icon icon="ant-design:video-camera-outlined" :size="16" /> |
| | | <span>è§é¢éè¯</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | <Tooltip v-else-if="!isQuitGroup" title="éè¯" placement="bottom"> |
| | | <Icon |
| | | icon="ant-design:phone-outlined" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | @click="handleGroupCall" |
| | | /> |
| | | </Tooltip> |
| | | <!-- ä¿¡æ¯æ½å±å
¥å£ï¼ç¾¤èæï¼ç§èä»
å¨å¯¹æ¹æ¯å¥½åæ¶æ¾ç¤ºï¼é好åä¼è¯æ½å±å
å®¹ä¸ºç©ºï¼æ²¡æä¹ï¼ --> |
| | | <Tooltip |
| | | v-if="isGroup || (isPrivate && !showNotFriendBanner)" |
| | | :title="isGroup ? '群èä¿¡æ¯' : 'è天信æ¯'" |
| | | placement="bottom" |
| | | > |
| | | <Icon |
| | | icon="ant-design:ellipsis-outlined" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | @click="toggleSide" |
| | | /> |
| | | </Tooltip> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 群éè¯è¶åæ¡ï¼ä»
群è + è¯¥ç¾¤ææ´»è·éè¯æ¶æ¾ç¤ºï¼ç¹å»å±å¼çæå + å å
¥æé® --> |
| | | <RtcGroupCallBanner |
| | | v-if="isGroup && !isQuitGroup && conversationStore.activeConversation" |
| | | :group-id="conversationStore.activeConversation.targetId" |
| | | /> |
| | | |
| | | <!-- ç¾¤ç½®é¡¶æ¶æ¯ï¼ç¬¬äºè¡åµå
¥ headerï¼ä»
群è + æç½®é¡¶æ¶æ¾ç¤º --> |
| | | <GroupPinnedMessage |
| | | v-if="isGroup && conversationStore.activeConversation" |
| | | :group-id="conversationStore.activeConversation.targetId" |
| | | @locate="handleLocate" |
| | | /> |
| | | <!-- 群顶é¨ãå¾
å¤çå 群ç³è¯·ã横å¹
ï¼ä»
群è + owner / admin + count > 0 æ¶æ¾ç¤º --> |
| | | <GroupRequestPending |
| | | v-if="isGroup && !isQuitGroup && conversationStore.activeConversation" |
| | | :group-id="conversationStore.activeConversation.targetId" |
| | | /> |
| | | <!-- ç§èï¼å¯¹æ¹ä¸åæ¯ææå¥½åï¼æå äºå¯¹æ¹ / 仿ªå è¿ï¼å边设计ä¸ã被对æ¹å é¤ãæ¬ç«¯ friendStore 䏿´æ°æ
ä¸ä¼è§¦åï¼ï¼è¶ååµå¨ header å
ï¼è·ç¾¤ç½®é¡¶å级ï¼ï¼ç¹å»å¼¹ UserInfoCard --> |
| | | <div |
| | | v-if="showNotFriendBanner" |
| | | class="flex flex-shrink-0 items-start px-4 pb-2 bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <div |
| | | class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--ant-color-text)] bg-[var(--ant-color-warning-bg)] transition-colors hover:bg-[var(--ant-color-warning-bg-hover)]" |
| | | @click="handleNotFriendClick" |
| | | > |
| | | <span |
| | | class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--ant-color-warning)] flex-shrink-0" |
| | | > |
| | | <Icon icon="ant-design:user-outlined" :size="11" /> |
| | | </span> |
| | | <span>对æ¹è¿ä¸æ¯ä½ çæå</span> |
| | | <Icon icon="ep:arrow-right" :size="12" class="text-[var(--ant-color-text-secondary)]" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ä¸é´ï¼æ¶æ¯å表 --> |
| | | <div |
| | | ref="listRef" |
| | | class="relative flex-1 py-2 overflow-y-auto bg-[var(--ant-color-bg-layout)]" |
| | | @scroll="handleScroll" |
| | | > |
| | | <div |
| | | v-if="messages.length === 0" |
| | | class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | ææ æ¶æ¯ |
| | | </div> |
| | | <!-- data-message-id ç» MessageHistory "å®ä½å°è天ä½ç½®" ç¨ï¼ç¶çº§éè¿ querySelector |
| | | æ¾å°è¿å± wrapperï¼scrollIntoView + å é«äº® classï¼æ¬å°å 使¶æ¯è·³è¿ --> |
| | | <div |
| | | v-for="(msg, index) in messages" |
| | | :key="msg.id || msg.clientMessageId" |
| | | :data-message-id="msg.id || ''" |
| | | class="message-panel__message-anchor" |
| | | > |
| | | <MessageItem |
| | | :message="msg" |
| | | :prev-message="messages[index - 1]" |
| | | @locate="handleLocate" |
| | | @mute="handleMuteMember" |
| | | @reload="reloadGroupData" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- åå°åºé¨æµ®å¨æé®ï¼æ»å¨ä¸å¨åºé¨æ¶æ¾ç¤ºï¼ --> |
| | | <transition name="message-panel__jump-fade"> |
| | | <div |
| | | v-if="showJumpToBottom" |
| | | class="message-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--ant-color-bg-elevated)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]" |
| | | @click="scrollToBottom(true)" |
| | | > |
| | | <Icon icon="ant-design:down-outlined" :size="14" /> |
| | | <span v-if="newMessageCount > 0" class="font-medium"> |
| | | {{ newMessageCount > 99 ? '99+' : newMessageCount }} æ¡æ°æ¶æ¯ |
| | | </span> |
| | | <span v-else>åå°åºé¨</span> |
| | | </div> |
| | | </transition> |
| | | </div> |
| | | |
| | | <!-- åºé¨ï¼è¾å
¥æ¡ï¼é¢éååæ¶æ¯æ éè¾å
¥æ¡ï¼ï¼å¤é模å¼åºæ ä½ä¸ºæµ®å±çå¨ä¸é¢ï¼ä¿æä¸æ¹è¾å
¥æ¡å°ºå¯¸ä¸å --> |
| | | <div v-if="!isChannel" class="relative"> |
| | | <MessageInput /> |
| | | <MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" /> |
| | | </div> |
| | | |
| | | <!-- å³ä¾§ä¿¡æ¯æ½å±ï¼ç¾¤è / ç§èåèªä¸ä»½ --> |
| | | <ConversationGroupSide |
| | | v-if="isGroup" |
| | | v-model="sideVisible" |
| | | :group="groupInfo" |
| | | :conversation="conversationStore.activeConversation" |
| | | :members="groupMembers" |
| | | @reload="reloadGroupData" |
| | | @open-history="historyDialogRef?.open()" |
| | | /> |
| | | <ConversationPrivateSide |
| | | v-else |
| | | v-model="sideVisible" |
| | | :conversation="conversationStore.activeConversation" |
| | | :friend="privateFriend" |
| | | @open-history="historyDialogRef?.open()" |
| | | /> |
| | | |
| | | <!-- å岿¶æ¯æ½å± --> |
| | | <MessageHistory ref="historyDialogRef" @locate="handleLocate" /> |
| | | |
| | | <!-- 转åå¼¹çª / åå¹¶æ¶æ¯è¯¦æ
ï¼å¨ MessagePanel åæ å
æè½½ï¼åç»ä»¶éè¿ inject 触å --> |
| | | <MessageForwardDialog ref="forwardDialogRef" /> |
| | | <MessageMergeDetailDialog ref="mergeDetailDialogRef" /> |
| | | |
| | | <!-- ç¦è¨æ¶é¿éæ©å¼¹çª --> |
| | | <GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" /> |
| | | |
| | | <!-- 群éè¯æåéæ©å¼¹çª --> |
| | | <RtcCallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" /> |
| | | </template> |
| | | <div |
| | | v-else |
| | | class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <span>éæ©ä¸ä¸ªä¼è¯å¼å§è天</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* :deep ç©¿é el-icon åç»ä»¶ svgï¼el-icon å
¨å±è§å .el-icon{color:var(--color,inherit)} ä¼å
级æ´é«ï¼ |
| | | ç¨ :deep + !important éè²ï¼é¢è²å Element Plus 主é¢åéï¼æè²æ¨¡å¼èªå¨åå°æ´äº®çç° */ |
| | | .message-panel__header-icon, |
| | | .message-panel__header-icon :deep(svg) { |
| | | width: 20px; |
| | | height: 20px; |
| | | color: var(--ant-color-text) !important; |
| | | fill: currentColor !important; |
| | | transition: color 0.15s; |
| | | } |
| | | |
| | | .message-panel__header-icon { |
| | | box-sizing: content-box; |
| | | padding: 4px; |
| | | } |
| | | .message-panel__header-icon:hover, |
| | | .message-panel__header-icon:hover :deep(svg) { |
| | | color: var(--ant-color-primary) !important; |
| | | } |
| | | |
| | | /* sticky + translate å±
ä¸ï¼fit-content 宽度ä¸ä¼ææ»¡ï¼transform æ°´å¹³ -50% åç§»ï¼åæ¶ transition opacity å transform ä¸¤ä¸ªå±æ§ */ |
| | | .message-panel__jump-bottom { |
| | | transform: translateX(-50%); |
| | | transition: |
| | | opacity 0.2s, |
| | | transform 0.2s; |
| | | } |
| | | |
| | | /* JS 忢 highlight class + background-color transition èå¨ï¼MessageHistoryãå®ä½ãè·³è¿æ¥æ¶çæé«äº® 1.6s */ |
| | | .message-panel__message-anchor { |
| | | transition: background-color 0.6s ease; |
| | | } |
| | | .message-panel__message-anchor--highlight { |
| | | background-color: var(--ant-color-warning-bg); |
| | | } |
| | | |
| | | /* Vue <transition> é©åç±»ååºå®ï¼å¿
é¡» SCSS 声æï¼åå°åºé¨æé®æ·¡å
¥æ·¡åº */ |
| | | .message-panel__jump-fade-enter-active, |
| | | .message-panel__jump-fade-leave-active { |
| | | transition: |
| | | opacity 0.2s, |
| | | transform 0.2s; |
| | | } |
| | | |
| | | .message-panel__jump-fade-enter-from, |
| | | .message-panel__jump-fade-leave-to { |
| | | opacity: 0; |
| | | transform: translate(-50%, 20px); |
| | | } |
| | | </style> |
| | | |
| | | <style> |
| | | /* el-popover å
¨å±æ ·å¼ï¼ç´§è´´èåçå° paddingï¼é scoped æè½å½ä¸ popper-class */ |
| | | .message-panel__call-popover.el-popover.el-popper { |
| | | min-width: auto; |
| | | padding: 6px; |
| | | } |
| | | </style> |