| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../../../../components/group' |
| | | import type { Message } from '../../../../types' |
| | | |
| | | import { computed, inject } from 'vue' |
| | | |
| | | import { confirm } from '#/packages/effects/common-ui/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { useUserStore } from '#/packages/stores/src' |
| | | |
| | | import { useClipboard } from '@vueuse/core' |
| | | import { message as antdMessage, Tag } from 'ant-design-vue' |
| | | |
| | | import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '#/api/im/group' |
| | | import { removeGroupMember } from '#/api/im/group/member' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { |
| | | MESSAGE_GROUP_READ_ENABLED, |
| | | MESSAGE_PRIVATE_READ_ENABLED, |
| | | MESSAGE_RECALL_WINDOW_MS, |
| | | MESSAGE_TIME_TIP_GAP_MS |
| | | } from '#/views/im/utils/config' |
| | | import { |
| | | ImContentType, |
| | | ImConversationType, |
| | | ImForwardMode, |
| | | ImFriendAddSource, |
| | | ImGroupMemberRole, |
| | | ImMessageReceiptStatus, |
| | | ImMessageStatus, |
| | | isFriendChatTip, |
| | | isGroupNotification, |
| | | isMediaMessageType, |
| | | isNormalMessage, |
| | | isRtcCallTip |
| | | } from '#/views/im/utils/constants' |
| | | import { buildRecallTipSegments } from '#/views/im/utils/conversation' |
| | | import { |
| | | buildQuoteFromMessage, |
| | | type CardMessage, |
| | | extractAddableFace, |
| | | getQuoteFromMessage, |
| | | type MentionCandidate, |
| | | parseMessage, |
| | | type TextMessage |
| | | } from '#/views/im/utils/message' |
| | | import { |
| | | parseRtcCallPayload, |
| | | resolveFriendNotificationSegments, |
| | | resolveGroupNotificationSegments, |
| | | resolveRtcCallPrivateBubbleText, |
| | | resolveRtcCallTipSegments |
| | | } from '#/views/im/utils/message' |
| | | import { formatTimeTip } from '#/views/im/utils/time' |
| | | import { |
| | | getMemberDisplayName, |
| | | getMentionCandidates, |
| | | getSenderDisplayName, |
| | | getSenderRealNickname, |
| | | isGroupQuit |
| | | } from '#/views/im/utils/user' |
| | | |
| | | import { UserAvatar } from '../../../../components/user' |
| | | import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader' |
| | | import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect' |
| | | import { useMessageSender } from '../../../../composables/useMessageSender' |
| | | import { useMuteOverlay } from '../../../../composables/useMuteOverlay' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useFaceStore } from '../../../../store/faceStore' |
| | | import { useFriendStore } from '../../../../store/friendStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | import { useImUiStore } from '../../../../store/uiStore' |
| | | import { |
| | | IM_FORWARD_DIALOG_KEY, |
| | | IM_MERGE_DETAIL_DIALOG_KEY, |
| | | IM_RTC_REDIAL_KEY |
| | | } from './forward/keys' |
| | | import MessageBubble from './message-bubble.vue' |
| | | import MessageReadStatus from './message-read-status.vue' |
| | | import ReplyPreview from './reply-preview.vue' |
| | | import TipSegments from './tip-segments.vue' |
| | | |
| | | defineOptions({ name: 'ImMessageItem' }) |
| | | |
| | | const props = defineProps<{ |
| | | message: Message |
| | | /** å表ä¸çä¸ä¸æ¡æ¶æ¯ï¼ç¨äºå¤ææ¯å¦è¦å¨å½åæ¶æ¯ä¸æ¹æ¸²ææ¶é´åéæ¡ï¼ä¸ä¼ æåè¡¨ç¬¬ä¸æ¡å¤ç */ |
| | | prevMessage?: Message |
| | | }>() |
| | | |
| | | const emit = defineEmits<{ |
| | | /** å¼ç¨åç¹å» â MessagePanel æ»å®ä½ + é«äº® */ |
| | | locate: [messageId: number] |
| | | /** ç¦è¨ï¼éè¦ç¶ç»ä»¶æå¼æ¶é¿éæ©å¼¹çª */ |
| | | mute: [groupId: number, userId: number, displayName: string] |
| | | /** æ°æ®åæ´åå·æ°ç¾¤ä¿¡æ¯ */ |
| | | reload: [] |
| | | }>() |
| | | |
| | | // ==================== Stores / Hooks ==================== |
| | | |
| | | const userStore = useUserStore() |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const groupStore = useGroupStore() |
| | | const friendStore = useFriendStore() |
| | | const faceStore = useFaceStore() |
| | | const uiStore = useImUiStore() |
| | | const { recall, sendRaw } = useMessageSender() |
| | | const { uploadAndSendMedia } = useMediaUploader() |
| | | const muteOverlay = useMuteOverlay() |
| | | // legacy:true å
¼å®¹ HTTP ç¯å¢ï¼æ²¡æ navigator.clipboard æ¶éçº§å° execCommand |
| | | const { copy: copyToClipboard } = useClipboard({ legacy: true }) |
| | | |
| | | // ==================== å
容类å夿 ==================== |
| | | |
| | | /** æ¯å¦å¨å½åæ¶æ¯ä¸æ¹æ¸²ææ¶é´åéæ¡ï¼åè¡¨ç¬¬ä¸æ¡ / è·ä¸ä¸æ¡è¶
è¿éå¼ï¼ç¼º sendTime 䏿¸²æï¼é¢éç´ ææ¯æ¡é½æ¾ç¤º */ |
| | | const shouldShowTimeTip = computed(() => { |
| | | if (!props.message.sendTime) { |
| | | return false |
| | | } |
| | | if (isMaterial.value) { |
| | | return true |
| | | } |
| | | if (!props.prevMessage?.sendTime) { |
| | | return true |
| | | } |
| | | return props.message.sendTime - props.prevMessage.sendTime > MESSAGE_TIME_TIP_GAP_MS |
| | | }) |
| | | |
| | | /** ä»
MessageItem èªèº«ä»è¦ç¨å°ç type å¤å®ï¼å
¶å®åæ¯å·²ä¸æ²å° MessageBubbleï¼ */ |
| | | const isVoice = computed(() => props.message.type === ImContentType.VOICE) |
| | | const isMerge = computed(() => props.message.type === ImContentType.MERGE) |
| | | /** |
| | | * é¢éç´ æå¨ãé¢éä¼è¯ãå
仿微信å
¬ä¼å·æ ·å¼ï¼å±
ä¸ + æ 头åï¼ï¼ |
| | | * ç§è / 群èé被转åè¿æ¥çç´ ææ selfSend èµ°æ åæ°æ³¡å¸å±ï¼èªå·±å³ã对æ¹å·¦ã带头åï¼ |
| | | */ |
| | | const isMaterial = computed( |
| | | () => |
| | | props.message.type === ImContentType.MATERIAL && |
| | | conversationStore.activeConversation?.type === ImConversationType.CHANNEL |
| | | ) |
| | | |
| | | /** å½åæ¯å¦å¨å
¬ä¼å· / é¢éä¼è¯å
ï¼éå¶å³é®èååªå±ç¤ºè½¬å / å é¤ */ |
| | | const isChannelConversation = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL |
| | | ) |
| | | |
| | | // ==================== äºä»¶æ¶æ¯ï¼æ¤å / 好å / 群广æï¼ ==================== |
| | | // è¿ä¸ç±»ä¸èµ°æ®éæ°æ³¡ï¼æ¸²ææå±
ä¸ç°è² tipï¼å¤æ + ææ¡é
对æ¾ä¸èµ·ï¼æ°å¢ç¬¬åç±»äºä»¶åªé卿¬åæ¹å® |
| | | |
| | | /** æ¯å¦å·²æ¤åï¼pull / WS 两路é½ä¼è° recallMessage æåæ¶æ¯æ´æ°ä¸º type=RECALLï¼æ¸²æåªéè¯å« type */ |
| | | const isRecall = computed(() => props.message.type === ImContentType.RECALL) |
| | | |
| | | /** æ¤åæç¤º segmentsï¼ä¾èµ activeConversation 宿¶ç® sender å */ |
| | | const recallTipSegments = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return buildRecallTipSegments( |
| | | props.message.senderId, |
| | | props.message.selfSend, |
| | | conversation?.type ?? 0, |
| | | conversation?.targetId ?? 0 |
| | | ) |
| | | }) |
| | | |
| | | /** æ¯å¦ä¼è¯å
好åäºä»¶æ°æ³¡ï¼FRIEND_ADD / FRIEND_DELETEï¼ */ |
| | | const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type)) |
| | | |
| | | /** 好åäºä»¶ segments */ |
| | | const friendChatTipSegments = computed(() => resolveFriendNotificationSegments(props.message)) |
| | | |
| | | /** æ¯å¦ç¾¤å¹¿æäºä»¶ï¼GROUP_CREATE..GROUP_BANNED 段ä½ï¼æé¤ GROUP_MEMBER_SETTING_UPDATE 个人信å·ï¼ */ |
| | | const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type)) |
| | | |
| | | /** 群广æäºä»¶ segments */ |
| | | const groupNotificationSegments = computed(() => |
| | | resolveGroupNotificationSegments(props.message, (id: number) => |
| | | getSenderDisplayName(id, ImConversationType.GROUP, props.message.targetId ?? 0) |
| | | ) |
| | | ) |
| | | |
| | | /** ç§è RTC_CALL_END èµ°ãåæ°æ³¡ãï¼å·¦å³åå¸ + çµè¯å¾æ + ææ¡ï¼ï¼éç§èåºæ¯ä¸º null */ |
| | | const rtcCallEndPrivatePayload = computed(() => { |
| | | if (props.message.type !== ImContentType.RTC_CALL_END) { |
| | | return null |
| | | } |
| | | const payload = parseRtcCallPayload(props.message.content) |
| | | return payload?.conversationType === ImConversationType.PRIVATE ? payload : null |
| | | }) |
| | | |
| | | /** æ¯å¦ç§èéè¯æ°æ³¡ */ |
| | | const isRtcCallPrivateBubbleMessage = computed(() => rtcCallEndPrivatePayload.value !== null) |
| | | |
| | | /** ç§èéè¯æ°æ³¡ææ¡ï¼æ operatorUserId æ¯å¦å½åç¨æ·åºåï¼å¯¹é½å¾®ä¿¡ä¸¤ç«¯ä¸åè§è§ï¼ */ |
| | | const rtcCallPrivateBubbleText = computed(() => |
| | | resolveRtcCallPrivateBubbleText(rtcCallEndPrivatePayload.value) |
| | | ) |
| | | |
| | | /** æ¯å¦ä¼è¯å
群éè¯äºä»¶å±
ä¸ tip */ |
| | | const isRtcCallTipMessage = computed(() => { |
| | | if (!isRtcCallTip(props.message.type)) { |
| | | return false |
| | | } |
| | | const payload = parseRtcCallPayload(props.message.content) |
| | | if (payload?.conversationType !== ImConversationType.GROUP) { |
| | | return false |
| | | } |
| | | return !isRtcCallPrivateBubbleMessage.value |
| | | }) |
| | | |
| | | /** éè¯äºä»¶ segmentsï¼ä»
群è tip ç¨ */ |
| | | const rtcCallTipSegments = computed(() => resolveRtcCallTipSegments(props.message)) |
| | | |
| | | // ==================== æ¶æ¯å
容解æ / payload ==================== |
| | | |
| | | /** å¼ç¨å¯¹è±¡ï¼æ°æ³¡å
åµå
¥å±ç¤ºï¼éå¼ç¨æ¶æ¯è¿å nullï¼æ¨¡æ¿ v-if 䏿¸²æ */ |
| | | const quote = computed(() => getQuoteFromMessage(props.message.content)) |
| | | |
| | | const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY) // MessagePanel 注å
¥çå¼¹çªè§¦å彿° |
| | | const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY) |
| | | const redialRtcCall = inject(IM_RTC_REDIAL_KEY) |
| | | |
| | | /** ç§è RTC_CALL_END æ°æ³¡ç¹å»ï¼ç¨å款 mediaType 鿍 */ |
| | | function handleRtcCallBubbleClick() { |
| | | const mediaType = rtcCallEndPrivatePayload.value?.mediaType |
| | | if (mediaType == null) { |
| | | return |
| | | } |
| | | redialRtcCall?.(mediaType) |
| | | } |
| | | |
| | | const multiSelect = useMessageMultiSelect() // å¤é模å¼ï¼æ¨¡å级åä¾ composable |
| | | |
| | | /** åå¹¶æ¶æ¯æ°æ³¡ç¹å»ï¼æå¼è¯¦æ
å¼¹çªï¼åµå¥åå¹¶ç±å¼¹çªå
é¨ push æ ï¼ */ |
| | | function handleMergeOpen(content: string) { |
| | | openMergeDetail?.(content) |
| | | } |
| | | |
| | | /** ææ¬æ°æ³¡ @ mention åéååï¼ä»
ç¾¤æ¶æ¯ææï¼æ atUserIds åæ¥ç¾¤æåç宿µç§°ï¼é TEXT ä¸èµ° store 读ï¼è®© getMentionCandidates ç´æ¥è¿å稳å®ç©ºæ°ç» */ |
| | | const textMentions = computed<MentionCandidate[]>(() => { |
| | | if (props.message.type !== ImContentType.TEXT) { |
| | | return getMentionCandidates(undefined, null) |
| | | } |
| | | return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation) |
| | | }) |
| | | |
| | | /** åçç¹å»ï¼ç¨æ·åçå¼¹ UserInfoCardï¼ç¾¤åçå¼¹ GroupInfoCardï¼å
¶å® targetTypeï¼å«æ¹å
èæ°æ®ï¼å¿½ç¥ */ |
| | | function handleCardClick(card: CardMessage, e: MouseEvent) { |
| | | if (!card?.targetId) { |
| | | return |
| | | } |
| | | if (card.targetType === ImConversationType.PRIVATE) { |
| | | uiStore.openUserInfoCardAtEvent( |
| | | { id: card.targetId, nickname: card.name, avatar: card.avatar }, |
| | | e, |
| | | ImFriendAddSource.CARD |
| | | ) |
| | | return |
| | | } |
| | | if (card.targetType === ImConversationType.GROUP) { |
| | | uiStore.openGroupInfoCardAtEvent( |
| | | { |
| | | id: card.targetId, |
| | | name: card.name, |
| | | showImage: card.avatar, |
| | | memberCount: card.memberCount |
| | | }, |
| | | e |
| | | ) |
| | | } |
| | | } |
| | | |
| | | /** åªä½ä¸ä¼ ä¸ï¼MessageBubble èªæ¸²æé®ç½© / è¿åº¦æ¡ï¼å¤å±åªç¨ä½ showSendingLoading å¤å® */ |
| | | const isUploading = computed(() => props.message.uploadProgress != null) |
| | | |
| | | /** |
| | | * æ¯å¦å¨æ°æ³¡å°¾é¨æ¾ç¤ºãåéä¸ãloading 转å |
| | | * |
| | | * å¾ç / è§é¢ / æä»¶æ°æ³¡å
åµå·²æè¿åº¦åé¦ï¼é®ç½© / è¿åº¦æ¡ï¼ï¼å¤å± loading ä¸åå å ï¼ |
| | | * è¯é³æ°æ³¡åªæéº¦å
é£ + æ¶é¿ï¼æ å
åµè¿åº¦ï¼å¿
é¡»ä¿çå¤å± loading è®©ç¨æ·æç¥æ£å¨åé |
| | | */ |
| | | const showSendingLoading = computed( |
| | | () => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value) |
| | | ) |
| | | |
| | | // ==================== åé人 / 已读 / @ ==================== |
| | | |
| | | /** 群è + å¯¹æ¹æ¶æ¯ æ¶ï¼å¨æ°æ³¡ä¸æ¹æ¾ç¤ºåéè
æµç§° */ |
| | | const showSenderName = computed(() => { |
| | | if (props.message.selfSend) { |
| | | return false |
| | | } |
| | | return conversationStore.activeConversation?.type === ImConversationType.GROUP |
| | | }) |
| | | |
| | | /** åéè
头åï¼ç§èç conversation.avatar å°±æ¯å¯¹æ¹å¤´åï¼openConversation å
¥å约å®ï¼ */ |
| | | const senderAvatar = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || props.message.selfSend) { |
| | | return '' |
| | | } |
| | | if (conversation.type === ImConversationType.GROUP) { |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || '' |
| | | } |
| | | return conversation.avatar || '' |
| | | }) |
| | | |
| | | /** 头åè²å¡ fallback ææ¬ï¼æ°¸è¿æ¯ç宿µç§°ï¼ä¸æºå¤æ³¨ */ |
| | | const senderRealNickname = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return getSenderRealNickname( |
| | | props.message.senderId, |
| | | conversation?.type ?? 0, |
| | | conversation?.targetId ?? 0 |
| | | ) |
| | | }) |
| | | |
| | | /** æ°æ³¡ä¸æ¹åé人æ¾ç¤ºåï¼ä»
群èå¯¹æ¹æ¶æ¯æ¾ç¤ºï¼ï¼å¥½å夿³¨ > ç¾¤å¤æ³¨ > ç宿µç§° */ |
| | | const senderDisplayName = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return getSenderDisplayName( |
| | | props.message.senderId, |
| | | conversation?.type ?? 0, |
| | | conversation?.targetId ?? 0 |
| | | ) |
| | | }) |
| | | |
| | | /** ç§èã已读 / æªè¯»ãæï¼ä»
对èªå·±åéçç§èæ¶æ¯å±ç¤ºï¼ç§è已读å
¨å±å
³éæ¶ä¸åå±ç¤ºï¼ */ |
| | | const privateReadLabel = computed(() => { |
| | | if (!MESSAGE_PRIVATE_READ_ENABLED) { |
| | | return '' |
| | | } |
| | | if (!props.message.selfSend) { |
| | | return '' |
| | | } |
| | | if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) { |
| | | return '' |
| | | } |
| | | if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) { |
| | | return '已读' |
| | | } |
| | | if (props.message.receiptStatus === ImMessageReceiptStatus.PENDING) { |
| | | return 'æªè¯»' |
| | | } |
| | | return '' |
| | | }) |
| | | |
| | | /** æ¯å¦éè¦æ¾ç¤ºç¾¤åæ§ popoverï¼èªå·±åçç¾¤æ¶æ¯ä¸å端å¼å¯äºåæ§ï¼NO_RECEIPT 表示åéæ¶æªè¦æ±åæ§ï¼ä¸æ¸²æï¼ç¾¤å·²è¯»å
¨å±å
³éæ¶ç»ä¸ä¸å±ç¤ºï¼ */ |
| | | const showGroupReadStatus = computed(() => { |
| | | if (!MESSAGE_GROUP_READ_ENABLED) { |
| | | return false |
| | | } |
| | | if (!props.message.selfSend) { |
| | | return false |
| | | } |
| | | if (conversationStore.activeConversation?.type !== ImConversationType.GROUP) { |
| | | return false |
| | | } |
| | | const status = props.message.receiptStatus |
| | | if (status === undefined || status === null) { |
| | | return false |
| | | } |
| | | return status !== ImMessageReceiptStatus.NO_RECEIPT |
| | | }) |
| | | |
| | | /** å½å群æåï¼ä¾ MessageReadStatus è®¡ç®æªè¯»ååï¼æªå è½½å®æ¶å
åºç©ºæ°ç»ä¸æ¸²æï¼ */ |
| | | const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return (group?.members || []).map((member) => { |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status |
| | | } |
| | | }) |
| | | }) |
| | | |
| | | /** æ¯å¦ @æï¼ç¾¤æ¶æ¯å±ç¤ºå°å¾½æ ï¼ */ |
| | | const isAtMe = computed(() => { |
| | | const myId = getCurrentUserId() |
| | | if (!myId) { |
| | | return false |
| | | } |
| | | return (props.message.atUserIds || []).includes(myId) |
| | | }) |
| | | |
| | | // ==================== å³é®èå / æä½ ==================== |
| | | |
| | | // å³é®èå key 常éï¼push 端ååå端ä»åä¸å¤åï¼typo ç¼è¯æå°±è½æ |
| | | const MENU_KEYS = { |
| | | COPY: 'COPY', |
| | | REPLY: 'REPLY', |
| | | FORWARD: 'FORWARD', |
| | | MULTI_SELECT: 'MULTI_SELECT', |
| | | PIN: 'PIN', |
| | | ADD_TO_FACE: 'ADD_TO_FACE', |
| | | MUTE: 'MUTE', |
| | | UNMUTE: 'UNMUTE', |
| | | KICK: 'KICK', |
| | | RECALL: 'RECALL', |
| | | DELETE: 'DELETE' |
| | | } as const |
| | | type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS] |
| | | |
| | | /** |
| | | * å³é®èåé¡¹ï¼ |
| | | * - å¼ç¨ï¼å·²è½åº + æªæ¤åçæ¶æ¯å¯å¼ç¨ï¼å¼ç¨ååå
¥ä¼è¯è稿 |
| | | * - æ¤å / å é¤ï¼äºæ¥ï¼èªå·±åé + å·²è½åº + æªæ¤å + 2 åéå
æ¾ç¤ºãæ¤åãï¼æ¨æå¡å¨ï¼ï¼å
¶å®æ¾ç¤ºãå é¤ãï¼ä»
æ¬å°æ¸
ï¼ |
| | | * |
| | | * 好åäºä»¶æ°æ³¡æä¸å¼¹èå |
| | | */ |
| | | async function handleContextMenu(e: MouseEvent) { |
| | | if (isFriendChatTipMessage.value) { |
| | | return |
| | | } |
| | | // å¤é模å¼ä¸ä¸å¼¹èåï¼å³é®ç¹å»å°±å½åæ¢å¾éï¼ä¸åå»è¡ä¸ºä¸è´ |
| | | if (isInMultiSelect.value) { |
| | | if (canForward.value) { |
| | | multiSelect.toggle(props.message) |
| | | } |
| | | return |
| | | } |
| | | |
| | | type MenuItem = { |
| | | danger?: boolean |
| | | disabled?: boolean |
| | | divided?: boolean |
| | | icon?: string |
| | | key: MenuKey |
| | | name: string |
| | | } |
| | | // é¢éï¼å
¬ä¼å·ï¼ä¼è¯ååæ¶æ¯ï¼åªä¿ç转å + æ¬å°å é¤ï¼ä¸æ¯æå¤å¶ / å¼ç¨ / å¤é / æ¤å / 群管çæä½ |
| | | if (isChannelConversation.value) { |
| | | const channelItems: MenuItem[] = [] |
| | | if (canForward.value) { |
| | | channelItems.push({ |
| | | key: MENU_KEYS.FORWARD, |
| | | name: '转å', |
| | | icon: 'ant-design:share-alt-outlined' |
| | | }) |
| | | } |
| | | channelItems.push({ |
| | | key: MENU_KEYS.DELETE, |
| | | name: 'å é¤', |
| | | icon: 'ant-design:delete-outlined', |
| | | divided: true, |
| | | danger: true |
| | | }) |
| | | uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, channelItems, async (item) => { |
| | | if (item.key === MENU_KEYS.FORWARD) { |
| | | handleForward() |
| | | } else if (item.key === MENU_KEYS.DELETE) { |
| | | handleDelete() |
| | | } |
| | | }) |
| | | return |
| | | } |
| | | |
| | | const items: MenuItem[] = [] |
| | | // ãå¤å¶ãï¼ä»
ææ¬æ¶æ¯æ¯æï¼æ¾å¨ç¬¬ä¸é¡¹ï¼å¯¹é½å¾®ä¿¡æ¡é¢å³é®ä¹ æ¯ |
| | | if (props.message.type === ImContentType.TEXT) { |
| | | items.push({ |
| | | key: MENU_KEYS.COPY, |
| | | name: 'å¤å¶', |
| | | icon: 'ant-design:copy-outlined' |
| | | }) |
| | | } |
| | | // ãå¼ç¨ãï¼å·²è½åº + æªæ¤å + éå并转åï¼MERGE å
åµå¿«ç
§å¨å¼ç¨é¢è§éæ æ³é级å±ç¤º |
| | | if (!!props.message.id && !isRecall.value && !isMerge.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.REPLY, |
| | | name: 'å¼ç¨', |
| | | icon: 'bxs:quote-alt-left' |
| | | }) |
| | | } |
| | | // ã转åããå¤éãï¼å·²è½åº + æ®éæ¶æ¯ + æªæ¤åï¼è§¦å ForwardDialog / è¿å
¥å¤éæ¨¡å¼ |
| | | if (canForward.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.FORWARD, |
| | | name: '转å', |
| | | icon: 'ant-design:share-alt-outlined' |
| | | }, { |
| | | key: MENU_KEYS.MULTI_SELECT, |
| | | name: 'å¤é', |
| | | icon: 'ant-design:check-square-outlined' |
| | | }) |
| | | } |
| | | // ã置顶ãï¼ä»
群è + æ®éæ¶æ¯ + å·²è½åº + æªæ¤å + 群主æç®¡çåï¼å·²ç½®é¡¶ä¸åå±ç¤ºï¼ç±ç½®é¡¶é¢æ¿çãç§»é¤ãå
¥å£æ¿æ¥ |
| | | // 䏿¬å°é¢å¤ä¸éï¼è®©åç«¯æ ¡éªï¼è¶
éæ¶éè¿ error toast åé¦ |
| | | if (canPin.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.PIN, |
| | | name: '置顶', |
| | | icon: 'ant-design:pushpin-outlined' |
| | | }) |
| | | } |
| | | // ãæ·»å å°è¡¨æ
ãï¼FACE / IMAGE æ¶æ¯ + å·²è½åº + æªæ¤åï¼åå
¥ä¸ªäººè¡¨æ
å
ï¼å¯¹ç
§å¾®ä¿¡ãæ·»å å°è¡¨æ
ã |
| | | if (canAddToFace.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.ADD_TO_FACE, |
| | | name: 'æ·»å å°è¡¨æ
', |
| | | icon: 'ant-design:smile-outlined' |
| | | }) |
| | | } |
| | | // ãç¦è¨ / è§£ç¦ / ç§»é¤ãï¼ç¾¤è + éèªå·±æ¶æ¯ + ææ¯ç¾¤ä¸»æç®¡çå |
| | | if (currentGroup.value && !props.message.selfSend && canManageSender.value) { |
| | | const senderMember = currentGroup.value.members?.find( |
| | | (m) => m.userId === props.message.senderId |
| | | ) |
| | | const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date() |
| | | if (isMuted) { |
| | | items.push({ |
| | | key: MENU_KEYS.UNMUTE, |
| | | name: 'è§£é¤ç¦è¨', |
| | | icon: 'ant-design:audio-outlined', |
| | | divided: true |
| | | }) |
| | | } else { |
| | | items.push({ |
| | | key: MENU_KEYS.MUTE, |
| | | name: 'ç¦è¨', |
| | | icon: 'ant-design:audio-muted-outlined', |
| | | divided: true |
| | | }) |
| | | } |
| | | items.push({ |
| | | key: MENU_KEYS.KICK, |
| | | name: 'ç§»é¤', |
| | | icon: 'ant-design:user-delete-outlined', |
| | | danger: true |
| | | }) |
| | | } |
| | | // ãæ¤å / å é¤ãäºéä¸ï¼ |
| | | // - èªå·±åé + å·²è½åº + æªæ¤å + 卿¤åçªå£å
â æ¤åï¼æ¨æå¡å¨ææ¶æ¯æç½® RECALLï¼ |
| | | // - å
¶å®ï¼å¯¹æ¹æ¶æ¯ / å·²æ¤å / è¶
åºæ¤åçªå£ï¼â å é¤ï¼ä»
æ¬å°æ¸
ï¼ä¸å¨åç«¯ï¼ |
| | | // divided æè¿ä¸é¡¹åä¸é¢çãå¼ç¨ãéå¼ï¼danger æ¾çº¢å¯¹é½å¾®ä¿¡ |
| | | const canRecall = |
| | | props.message.selfSend && |
| | | !!props.message.id && |
| | | !isRecall.value && |
| | | Date.now() - props.message.sendTime <= MESSAGE_RECALL_WINDOW_MS |
| | | if (canRecall) { |
| | | items.push({ |
| | | key: MENU_KEYS.RECALL, |
| | | name: 'æ¤å', |
| | | icon: 'ant-design:undo-outlined', |
| | | divided: true, |
| | | danger: true |
| | | }) |
| | | } else { |
| | | items.push({ |
| | | key: MENU_KEYS.DELETE, |
| | | name: 'å é¤', |
| | | icon: 'ant-design:delete-outlined', |
| | | divided: true, |
| | | danger: true |
| | | }) |
| | | } |
| | | |
| | | // æè忏²æäº¤ç»å
¨å± uiStoreï¼åä¾ï¼é¿å
æ¯æ¡æ¶æ¯é½æä¸ä»½èå DOMï¼ |
| | | const menuHandlers: Record<MenuKey, () => Promise<void> | void> = { |
| | | [MENU_KEYS.COPY]: handleCopy, |
| | | [MENU_KEYS.REPLY]: handleReply, |
| | | [MENU_KEYS.FORWARD]: handleForward, |
| | | [MENU_KEYS.MULTI_SELECT]: handleEnterMultiSelect, |
| | | [MENU_KEYS.PIN]: handlePin, |
| | | [MENU_KEYS.ADD_TO_FACE]: handleAddToFace, |
| | | [MENU_KEYS.MUTE]: handleMute, |
| | | [MENU_KEYS.UNMUTE]: handleUnmute, |
| | | [MENU_KEYS.KICK]: handleKick, |
| | | [MENU_KEYS.RECALL]: handleRecall, |
| | | [MENU_KEYS.DELETE]: handleDelete |
| | | } |
| | | uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => { |
| | | await menuHandlers[item.key as MenuKey]?.() |
| | | }) |
| | | } |
| | | |
| | | /** æ¯å¦å¯ãæ·»å å°è¡¨æ
ãï¼FACE / IMAGE æ¶æ¯ + å·²è½åº + æªæ¤åï¼GIF / éå¾é½å
è®¸ï¼ */ |
| | | const canAddToFace = computed(() => { |
| | | if (isRecall.value || !props.message.id) { |
| | | return false |
| | | } |
| | | return extractAddableFace(props.message) !== null |
| | | }) |
| | | |
| | | /** æ·»å å°ä¸ªäººè¡¨æ
ï¼ä» message æ½ url + 尺寸 + name åå
¥ä¸ªäººè¡¨æ
åº */ |
| | | async function handleAddToFace() { |
| | | const payload = extractAddableFace(props.message) |
| | | if (!payload) { |
| | | return |
| | | } |
| | | const data = await faceStore.addFaceUserItem(payload) |
| | | if (data) { |
| | | antdMessage.success('已添å å°è¡¨æ
') |
| | | } |
| | | } |
| | | |
| | | /** å½åæ¿æ´»ä¼è¯å¯¹åºç群ï¼ç§èåºæ¯ä¸º undefinedï¼ */ |
| | | const currentGroup = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return undefined |
| | | } |
| | | return groupStore.getGroup(conversation.targetId) |
| | | }) |
| | | |
| | | /** å½åç¨æ·å¨è¯¥ç¾¤éçè§è²ï¼ç§èæé群æå â undefined */ |
| | | const myGroupRole = computed(() => { |
| | | const myId = getCurrentUserId() |
| | | return currentGroup.value?.members?.find((m) => m.userId === myId)?.role |
| | | }) |
| | | |
| | | /** æ¯å¦å¯ç®¡çè¯¥æ¶æ¯åéäººï¼æçè§è²é«äºç®æ è§è²ï¼ç¾¤ä¸» > 管çå > æ®éæåï¼ï¼ç®æ è§è²æªç¥æ¶ä¸å±ç¤º */ |
| | | const canManageSender = computed(() => { |
| | | // åå²éç¾¤ç¾¤ï¼æ¬å°å¯è½æ®çæåç¼åï¼æ¾å¼æé¤ï¼é¿å
å³é®ä»åºãç¦è¨ / ç§»é¤ã |
| | | if (!currentGroup.value || isGroupQuit(currentGroup.value) || !myGroupRole.value) { |
| | | return false |
| | | } |
| | | const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId) |
| | | // æåç¼åä¸å®æ´æ¶ä¸å±ç¤ºç®¡çæä½ï¼çæåæ°æ®è¡¥é½åå夿 |
| | | if (!senderMember?.role) { |
| | | return false |
| | | } |
| | | // è§è²æ°å¼ï¼1=群主 2=管çå 3=æ®éæåï¼æ°å¼è¶å°æéè¶é« |
| | | return myGroupRole.value < senderMember.role |
| | | }) |
| | | |
| | | /** æ¯å¦å
许转å / å¤éï¼æ®éæ¶æ¯ + å·²è½åº + æªæ¤åï¼æ¬å°å ä½ / æ¤å / äºä»¶æ¶æ¯ä¸å¾ä¸å¯ */ |
| | | const canForward = computed( |
| | | () => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value |
| | | ) |
| | | |
| | | /** å¤éæ¨¡å¼æ¯å¦æ¿æ´»ï¼åæ¢ä¼è¯ç± index.vue ä¸»å¨ exitMultiSelect */ |
| | | const isInMultiSelect = computed(() => multiSelect.state.active) |
| | | |
| | | /** å½åæ¶æ¯æ¯å¦å·²å¾é */ |
| | | const isMessageChecked = computed(() => { |
| | | if (!isInMultiSelect.value) { |
| | | return false |
| | | } |
| | | return multiSelect.selectedIdSet.value.has(props.message.clientMessageId) |
| | | }) |
| | | |
| | | /** å¤é模å¼ä¸ç¹å»æ°æ³¡ï¼å¯è½¬åçæ¶æ¯åæ¢å¾éï¼äºä»¶ / æ¤å / å ä½çä¸ååºï¼ç´æ¥åæç¹å»é¿å
触åå¾çé¢è§çï¼ */ |
| | | function handleMultiSelectClick(e: MouseEvent) { |
| | | if (!isInMultiSelect.value) { |
| | | return |
| | | } |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | if (!canForward.value) { |
| | | return |
| | | } |
| | | multiSelect.toggle(props.message) |
| | | } |
| | | |
| | | /** æ¯å¦å
许置顶ï¼å·²ç½®é¡¶æ¶æ¯ä¸åå±ç¤ºèå项ï¼ç±ç½®é¡¶é¢æ¿çãç§»é¤ãå
¥å£æ¿æ¥ï¼ï¼ç¾¤è + æ®éæ¶æ¯ + å·²è½åº + æªæ¤å + 群主æç®¡çå + æªç½®é¡¶ */ |
| | | const canPin = computed( |
| | | () => |
| | | !!currentGroup.value && |
| | | !isGroupQuit(currentGroup.value) && |
| | | isNormalMessage(props.message.type) && |
| | | !!props.message.id && |
| | | !isRecall.value && |
| | | (myGroupRole.value === ImGroupMemberRole.OWNER || |
| | | myGroupRole.value === ImGroupMemberRole.ADMIN) && |
| | | !currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id) |
| | | ) |
| | | |
| | | /** ç½®é¡¶æ¶æ¯ï¼äºæ¬¡ç¡®è®¤ â è°å端 pin-messageï¼å端广æ GROUP_MESSAGE_PINï¼æ¬ç«¯ dispatcher æææ° pinnedMessages */ |
| | | async function handlePin() { |
| | | const group = currentGroup.value |
| | | if (!group || !props.message.id) { |
| | | return |
| | | } |
| | | try { |
| | | await confirm('å°å¨å½å群æåçè天ä¸ç½®é¡¶', 'ç½®é¡¶æ¶æ¯') |
| | | await apiPinGroupMessage({ id: group.id, messageId: props.message.id }) |
| | | antdMessage.success('已置顶') |
| | | } catch {} |
| | | } |
| | | |
| | | /** å¤å¶ææ¬æ¶æ¯ï¼è§£åº content åæ®µåå
¥åªè´´æ¿ï¼æç¤ºãå
容已å¤å¶å°åªè´´æ¿ã */ |
| | | async function handleCopy() { |
| | | const text = parseMessage<TextMessage>(props.message.content)?.content |
| | | if (!text) { |
| | | return |
| | | } |
| | | await copyToClipboard(text) |
| | | antdMessage.success('å
容已å¤å¶å°åªè´´æ¿') |
| | | } |
| | | |
| | | /** è¿å
¥å¼ç¨æ¨¡å¼ï¼æå½åæ¶æ¯æé æ QuoteMessage åå
¥ä¼è¯è稿 */ |
| | | function handleReply() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | conversationStore.setConversationReplyDraft(conversation, buildQuoteFromMessage(props.message)) |
| | | } |
| | | |
| | | /** 转åå½åæ¶æ¯ï¼æå¼ ForwardDialogï¼åæ¡æ¨¡å¼ï¼mode=single å³åæ ·è½¬ï¼ */ |
| | | function handleForward() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | openForwardDialog?.({ |
| | | mode: ImForwardMode.SINGLE, |
| | | messages: [props.message], |
| | | sourceConversation: conversation |
| | | }) |
| | | } |
| | | |
| | | /** è¿å
¥å¤é模å¼ï¼åå§å¾éå½åæ¶æ¯ */ |
| | | function handleEnterMultiSelect() { |
| | | multiSelect.enter(props.message) |
| | | } |
| | | |
| | | /** |
| | | * æ¤åæ¶æ¯ï¼å¼¹ç¡®è®¤æ¡ â è° useMessageSender.recall â å端éè¿ WS RECALL äºä»¶æ¨åï¼ |
| | | * websocketStore æå¯¹åº message ç type æ¹æ RECALLï¼UI èªå¨åå°"XX æ¤åäºä¸æ¡æ¶æ¯" |
| | | * |
| | | * ä¸åä¹è§æ¤åï¼å¤±è´¥ / è¶
æ¶ / å端æç»æ¶æ¬ç«¯ç¶æå¯è½ä¸æå¡ç«¯æ¼ç§»ï¼ç»ä¸è®© WS 忍æç¨³ |
| | | */ |
| | | async function handleRecall() { |
| | | try { |
| | | await confirm('ç¡®å®è¦æ¤åè¿æ¡æ¶æ¯åï¼', 'æ¤åæ¶æ¯') |
| | | await recall(props.message) |
| | | } catch {} |
| | | } |
| | | |
| | | /** |
| | | * å¤±è´¥æ¶æ¯ç¹å»éè¯ |
| | | * |
| | | * - åªä½æ¶æ¯ï¼image / file / voice / videoï¼ï¼_localFile å¨å
åå°±éèµ° uploadAndSendMediaï¼éæ°ä¸ä¼ + å ä½ + è¿åº¦ï¼ |
| | | * - ææ¬æ¶æ¯ï¼å¤ç¨å clientMessageId + status åæ»å° SENDINGï¼èµ° existingClientMessageId è·¯å¾è®©æå¡ç«¯æ cmid å¹ç |
| | | * |
| | | * åªä½ç±»åè¥ _localFile 已丢ï¼çè®ºä¸ IDB æ¢å¤é¶æ®µå°±è¢« dropï¼è¿ä¸å°è¿éï¼ä¿é©èµ·è§ä»èµ°ææ¬å
åºï¼åæææ¬è·¯å¾éåï¼ |
| | | * å端æç»å¤±æ blob URL æ¶å次 FAILEDï¼ç¨æ·å¯å³é®å é¤ |
| | | * |
| | | * ä¸è¿åå receiptï¼ç¾¤åæ§æ¯åéæ¶çæ©å±é项ãä¸ä¼æä¹
åå° messageï¼å¼ºè¡çæµå¯è½ä¸åæä¸ç¬¦ï¼ |
| | | * é»è®¤æ"æ åæ§"éåï¼ç»å¤§å¤æ°åºæ¯ç¬¦å颿ï¼è¦åæ§å°±éæ°å䏿¬¡æ´ç´è§ |
| | | */ |
| | | async function handleResend() { |
| | | if (props.message.status !== ImMessageStatus.FAILED) { |
| | | return |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | // ç¦è¨ / å°ç¦æ¶æ¦æªï¼é¿å
éè¯ç»è¿ MessageInput ç muteOverlay åèµ°ä¸æ¬¡ sendRaw 让å端æ |
| | | if (muteOverlay.value) { |
| | | return |
| | | } |
| | | const message = props.message |
| | | const file = message._localFile |
| | | |
| | | // åªä½ç±»å + _localFile å¨ â éèµ° uploadAndSendMediaï¼type åå + æ§å
æ°æ®å¤ç¨ç»ä¸å¨ mediaTypeHandlers 表é |
| | | if (isMediaMessageType(message.type) && file) { |
| | | const handler = mediaTypeHandlers[message.type] |
| | | if (handler) { |
| | | const oldQuote = getQuoteFromMessage(message.content) ?? undefined |
| | | const context = handler.extractResendContext(message.content) |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: message.type, |
| | | quote: oldQuote, |
| | | conversation, |
| | | context, |
| | | existingClientMessageId: message.clientMessageId |
| | | }) |
| | | return |
| | | } |
| | | } |
| | | |
| | | // ææ¬ç±»å / åªä½ç±»åä½ _localFile å·²ä¸¢ï¼æ FAILED å ä½åæ»å° SENDINGï¼å¤ç¨ clientMessageId 让æå¡ç«¯æ cmid å¹çå»é |
| | | messageStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, { |
| | | status: ImMessageStatus.SENDING |
| | | }) |
| | | await sendRaw(message.type, message.content, { |
| | | atUserIds: message.atUserIds, |
| | | existingClientMessageId: message.clientMessageId |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * å 餿¶æ¯ï¼æ¬å°è½¯å ï¼ä»
ä» messageStore ç§»é¤ï¼ä¸è°å端 |
| | | * åºå«äº"æ¤å"ï¼æå¡ç«¯æ²¡å¨ï¼å¤ç«¯ç»å½æ¶å
¶å®å®¢æ·ç«¯ / 群éå
¶ä»äººä¾ç¶è½çå°è¿æ¡ |
| | | */ |
| | | /** ç¦è¨ï¼emit ç»ç¶ç»ä»¶æå¼æ¶é¿éæ©å¼¹çªï¼é¿å
MessageItem è¿éï¼ */ |
| | | function handleMute() { |
| | | const group = currentGroup.value |
| | | if (!group) { |
| | | return |
| | | } |
| | | const name = senderDisplayName.value || '该æå' |
| | | emit('mute', group.id, props.message.senderId, name) |
| | | } |
| | | |
| | | /** è§£é¤ç¦è¨ */ |
| | | async function handleUnmute() { |
| | | const group = currentGroup.value |
| | | if (!group) { |
| | | return |
| | | } |
| | | try { |
| | | await confirm('ç¡®å®è§£é¤è¯¥æåçç¦è¨åï¼', 'è§£é¤ç¦è¨') |
| | | await cancelMuteMember({ id: group.id, userId: props.message.senderId }) |
| | | antdMessage.success('已解é¤ç¦è¨') |
| | | emit('reload') |
| | | } catch {} |
| | | } |
| | | |
| | | /** ç§»é¤ç¾¤æå */ |
| | | async function handleKick() { |
| | | const group = currentGroup.value |
| | | if (!group) { |
| | | return |
| | | } |
| | | const name = senderDisplayName.value || '该æå' |
| | | try { |
| | | await confirm(`ç¡®å®å°ã${name}ãç§»åºç¾¤èåï¼`, 'ç§»é¤æå') |
| | | await removeGroupMember({ groupId: group.id, memberUserIds: [props.message.senderId] }) |
| | | antdMessage.success('已移é¤') |
| | | emit('reload') |
| | | } catch {} |
| | | } |
| | | |
| | | function handleDelete() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | messageStore.removeMessage(conversation.type, conversation.targetId, { |
| | | id: props.message.id, |
| | | clientMessageId: props.message.clientMessageId |
| | | }) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- æ¶é´åéæ¡ï¼åè¡¨ç¬¬ä¸æ¡ / è·ä¸ä¸æ¡è¶
è¿é弿¶å±
䏿¾ç¤ºç°è²æ¶é´ --> |
| | | <div |
| | | v-if="shouldShowTimeTip" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ formatTimeTip(message.sendTime) }} |
| | | </div> |
| | | |
| | | <!-- 好åä¼è¯äºä»¶ï¼FRIEND_ADD / FRIEND_DELETEï¼ï¼è·ç¾¤å¹¿æäºä»¶åç°è²æ ·å¼ï¼ææ¡åºå® --> |
| | | <div |
| | | v-if="isFriendChatTipMessage" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="friendChatTipSegments" /> |
| | | </div> |
| | | |
| | | <!-- 群广æäºä»¶ï¼è·å¥½åäºä»¶åç°è²æ ·å¼ï¼mention 段æç¹å»å¼¹ UserInfoCard --> |
| | | <div |
| | | v-else-if="isGroupNotificationMessage" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="groupNotificationSegments" /> |
| | | </div> |
| | | |
| | | <!-- 群éè¯äºä»¶ï¼RTC_CALL_START / RTC_CALL_ENDï¼ï¼å±
ä¸ç°è² tip ä¸¤æ®µå¼ --> |
| | | <div |
| | | v-else-if="isRtcCallTipMessage" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="rtcCallTipSegments" /> |
| | | </div> |
| | | |
| | | <!-- ç§èéè¯ç»æï¼RTC_CALL_ENDï¼ï¼ä»¿å¾®ä¿¡ãåæ°æ³¡ãï¼æ selfSend å·¦å³åå¸ï¼çµè¯å¾æ + ææ¡ --> |
| | | <div v-else-if="isRtcCallPrivateBubbleMessage" class="flex gap-2 items-start px-4 py-2"> |
| | | <div |
| | | class="flex flex-1 min-w-0 gap-2 items-start" |
| | | :class="{ 'flex-row-reverse': message.selfSend }" |
| | | > |
| | | <UserAvatar |
| | | :id="message.selfSend ? getCurrentUserId() : message.senderId" |
| | | :name="senderRealNickname" |
| | | :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar" |
| | | :size="36" |
| | | /> |
| | | <div |
| | | class="flex gap-2 items-center px-3.5 py-2 text-sm rounded-lg cursor-pointer" |
| | | :class=" |
| | | message.selfSend |
| | | ? 'text-black bg-[#95ec69]' |
| | | : 'text-[var(--ant-color-text)] bg-[var(--ant-color-fill-secondary)]' |
| | | " |
| | | @click="handleRtcCallBubbleClick" |
| | | > |
| | | <Icon icon="ant-design:phone-outlined" :size="16" class="rotate-[135deg] flex-shrink-0" /> |
| | | <span class="whitespace-nowrap">{{ rtcCallPrivateBubbleText }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- æ¤åæ¶æ¯ï¼æ´è¡ç°è² tipï¼sender åæ®µå¯ç¹å» --> |
| | | <div |
| | | v-else-if="isRecall" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="recallTipSegments" /> |
| | | </div> |
| | | |
| | | <!-- æ®éæ¶æ¯æ°æ³¡ --> |
| | | <div |
| | | v-else |
| | | class="flex gap-2 items-start px-4 py-2" |
| | | :class="{ 'cursor-pointer': isInMultiSelect }" |
| | | @contextmenu.prevent="handleContextMenu" |
| | | @click.capture="handleMultiSelectClick" |
| | | > |
| | | <!-- å¤éæç¤ºï¼æ°¸è¿å¨æ´è¡æå·¦ä¾§ï¼èªå·± / å¯¹æ¹æ¶æ¯ä¸è´ï¼ï¼ä¸åä¸ selfSend reverse --> |
| | | <span |
| | | v-if="isInMultiSelect" |
| | | class="flex flex-shrink-0 items-center justify-center mt-3 w-5 h-5 rounded-full transition-colors" |
| | | :class=" |
| | | isMessageChecked |
| | | ? 'bg-[#07c160]' |
| | | : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]' |
| | | " |
| | | > |
| | | <Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" /> |
| | | </span> |
| | | |
| | | <!-- æ¶æ¯è¡ï¼å¤´å + æ°æ³¡å
鍿 selfSend reverseï¼é¢éç´ æä»¿å
¬ä¼å·æ ·å¼å±
ä¸ä¸ä¸æ¾ç¤ºå¤´å --> |
| | | <div |
| | | class="flex flex-1 min-w-0 gap-2 items-start" |
| | | :class="{ 'flex-row-reverse': message.selfSend, 'justify-center': isMaterial }" |
| | | > |
| | | <!-- 头åï¼ç¹å»å¼¹ UserInfoCard ç± UserAvatar å
鍿¿æ¥ï¼é¢éç´ ææ¶æ¯ä¸æ¾ç¤ºå¤´å --> |
| | | <UserAvatar |
| | | v-if="!isMaterial" |
| | | :id="message.selfSend ? getCurrentUserId() : message.senderId" |
| | | :name="senderRealNickname" |
| | | :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar" |
| | | :size="36" |
| | | /> |
| | | |
| | | <div |
| | | class="flex flex-col gap-0.5" |
| | | :class="[ |
| | | message.selfSend ? 'items-end' : '', |
| | | // å
¬ä¼å·ä¼è¯å
ç´ æï¼åºå® 360 宽ï¼å¯¹é½å¾®ä¿¡å
¬ä¼å·å¡çï¼ï¼å
¶å®ï¼å«ç§è / 群è转åçç´ æï¼ï¼70% ä¸éï¼æ°æ³¡èªå·±æå®½åº¦ |
| | | isMaterial ? 'w-[360px]' : 'max-w-[70%]' |
| | | ]" |
| | | > |
| | | <!-- 群èå¯¹æ¹æ¶æ¯ï¼æ°æ³¡ä¸æ¹æ¾ç¤ºåéè
æµç§° --> |
| | | <div |
| | | v-if="showSenderName" |
| | | class="mb-0.5 text-12px text-[var(--ant-color-text-secondary)] leading-tight" |
| | | > |
| | | {{ senderDisplayName }} |
| | | </div> |
| | | <div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }"> |
| | | <!-- æ¶æ¯å
å®¹ï¼æ type èµ° 9 ç±»æ°æ³¡ï¼ç»ä¸ç± MessageBubble 渲æ --> |
| | | <MessageBubble |
| | | :type="message.type" |
| | | :content="message.content" |
| | | :self-send="message.selfSend" |
| | | :upload-progress="message.uploadProgress" |
| | | :mentions="textMentions" |
| | | @click-card="handleCardClick" |
| | | @open-merge="handleMergeOpen" |
| | | /> |
| | | |
| | | <!-- ç¶æåºï¼èªå·±æ¶æ¯å±ç¤ºåéç¶æ + 已读/ç¾¤åæ§ï¼å¯¹æ¹æ¶æ¯ + @èªå·±æ¶å±ç¤º @å¾½æ --> |
| | | <div class="flex gap-1.5 items-center text-base"> |
| | | <template v-if="message.selfSend"> |
| | | <Icon |
| | | v-if="showSendingLoading" |
| | | icon="ant-design:loading-outlined" |
| | | class="im-loading-spin" |
| | | /> |
| | | <Icon |
| | | v-else-if="message.status === ImMessageStatus.FAILED" |
| | | icon="ant-design:warning-filled" |
| | | color="#f56c6c" |
| | | class="cursor-pointer" |
| | | title="åé失败ï¼ç¹å»éè¯" |
| | | @click="handleResend" |
| | | /> |
| | | <!-- 已读æï¼ç§èï¼ --> |
| | | <span |
| | | v-else-if="privateReadLabel" |
| | | class="text-12px whitespace-nowrap" |
| | | :class=" |
| | | message.receiptStatus === ImMessageReceiptStatus.DONE |
| | | ? 'text-[#409eff]' |
| | | : 'text-[var(--ant-color-text-secondary)]' |
| | | " |
| | | > |
| | | {{ privateReadLabel }} |
| | | </span> |
| | | <!-- ç¾¤åæ§ï¼ç¹å»å¼¹ popover å±ç¤ºå·²è¯» / æªè¯»æåå表 --> |
| | | <MessageReadStatus |
| | | v-else-if="showGroupReadStatus" |
| | | :message="message" |
| | | :group-id="conversationStore.activeConversation?.targetId || 0" |
| | | :group-members="groupMembersForReadStatus" |
| | | class="text-12px whitespace-nowrap text-[var(--ant-color-text-secondary)]" |
| | | /> |
| | | </template> |
| | | <!-- 群 @ ææç¤º --> |
| | | <Tag |
| | | v-if="!message.selfSend && isAtMe" |
| | | danger |
| | | size="small" |
| | | class="ml-1" |
| | | > |
| | | @æ |
| | | </Tag> |
| | | </div> |
| | | </div> |
| | | <!-- å¼ç¨åï¼æ°æ³¡ä¸æ¹ï¼selfSend æ¶ç«çº¿å¨å³ä¾§ --> |
| | | <ReplyPreview |
| | | v-if="quote" |
| | | :quote="quote" |
| | | clickable |
| | | :mirrored="message.selfSend" |
| | | class="max-w-[280px]" |
| | | @locate="emit('locate', $event)" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* @keyframes éè¦ SCSS 声æï¼SENDING ç¶æç转åå¨ç» */ |
| | | .im-loading-spin { |
| | | animation: im-loading-spin 1s linear infinite; |
| | | } |
| | | @keyframes im-loading-spin { |
| | | to { |
| | | transform: rotate(360deg); |
| | | } |
| | | } |
| | | </style> |