26 分钟以前 543433f64d491fe133b7a23363ff0ccacc3da07e
src/views/im/home/pages/conversation/components/message/message-item.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1055 @@
<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>