gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
src/views/im/utils/message.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1035 @@
import type { Conversation, GroupLite, Message, QuoteMessage, User } from '../home/types'
import { useUserStore } from '#/packages/stores/src'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore'
import {
  ImContentType,
  ImConversationType,
  type ImConversationTypeValue,
  ImRtcCallEndReason
} from './constants'
import { formatCallDuration } from './time'
export type { QuoteMessage } from '../home/types'
// ====================================================================
// IM æ¶ˆæ¯ content ç¼–解码 & å±•示工具
// ====================================================================
// çº¦å®šï¼šæ¶ˆæ¯çš„ content å­—段统一存 JSON å­—符串,字段名、结构对齐后端
// cn.iocoder.yudao.module.im.service.websocket.notification.message.* ä¸‹çš„ DTO。
// å„类消息 payload interface å­—段对齐后端;解析统一用 parseMessage<T>,
// åºåˆ—化直接 JSON.stringify(payload)。
// ====================================================================
// ==================== å®¢æˆ·ç«¯ ID ====================
/** ç”Ÿæˆå®¢æˆ·ç«¯æ¶ˆæ¯ ID(纯 UUID),用于前端去重 & ACK å›žå†™ */
export const generateClientMessageId = (): string => {
  if (globalThis.crypto?.randomUUID) {
    return globalThis.crypto.randomUUID()
  }
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAll(/[xy]/g, (char) => {
    const random = Math.trunc(Math.random() * 16)
    const value = char === 'x' ? random : (random & 0x3) | 0x8
    return value.toString(16)
  })
}
// ==================== ç§èŠå¯¹ç«¯ userId ====================
/**
 * ç§èŠæ¶ˆæ¯ / DTO çš„对端 userId:自己发的对端是 receiver,别人发的对端是 sender
 *
 * æ”¶å£ 4 å¤„æ—§ inline(websocketStore.convertPrivateMessage / handlePrivateMessage / computeFriendPeerId
 * å’Œ useMessagePuller.getPrivatePeerId),结构类型只要 senderId / receiverId ä¸¤ä¸ªå­—段,REST ä¸Ž WS DTO éƒ½æ»¡è¶³
 */
export function getPrivateMessagePeerId(
  message: { receiverId: number; senderId: number; },
  currentUserId: number
): number {
  return message.senderId === currentUserId ? message.receiverId : message.senderId
}
// ==================== æ–‡æœ¬ç‰‡æ®µï¼ˆtip æ–‡æ¡ˆ + TEXT æ°”泡共用) ====================
// æ—¢ç”¨äºŽç°æ¡ tip("XX é‚€è¯· YY åŠ å…¥ç¾¤èŠ"),也用于 TEXT æ°”泡正文(@xxx é«˜äº® + URL è‡ªåŠ¨è¯†åˆ«ï¼‰ã€‚
// mention æ®µæºå¸¦ userId ç”¨äºŽæŒ‚点击弹 UserInfoCard;link æ®µæºå¸¦ href ç”¨äºŽ <a> è·³è½¬ï¼›
// text æ®µæ˜¯çº¯æ–‡æœ¬å…œåº•。渲染层(tip-segments.vue)按 type åˆ†å‘统一处理。
export type TipSegment =
  | { href: string; text: string; type: 'link'; }
  | { text: string; type: 'mention'; userId: number; }
  | { text: string; type: 'text'; }
export const tipText = (text: string): TipSegment => ({ type: 'text', text })
export const tipMention = (userId: number, text: string): TipSegment => ({
  type: 'mention',
  userId,
  text
})
export const tipLink = (href: string, text: string): TipSegment => ({
  type: 'link',
  href,
  text
})
export const segmentsToText = (segments: TipSegment[]): string =>
  segments.map((s) => s.text).join('')
/** å¤šä¸ª userId ç”¨åŒä¸€ä¸ªåˆ†éš”符插值成 segments,每个 user å•独成 mention æ®µ */
export function joinMentionSegments(
  userIds: number[],
  separator: string,
  resolveName: (userId: number) => string
): TipSegment[] {
  return userIds.flatMap((id, index) =>
    index === 0
      ? [tipMention(id, resolveName(id))]
      : [tipText(separator), tipMention(id, resolveName(id))]
  )
}
/** mention å€™é€‰ï¼›name ç”¨äºŽåŒ¹é…æ–‡æœ¬å­—面量,displayName ç”¨äºŽè¦†ç›–渲染(不传则按 name æ¸²æŸ“) */
export interface MentionCandidate {
  userId: number
  /** ç”¨æ¥åœ¨ content ä¸­å‰ç¼€åŒ¹é…çš„字面量(不含 @) */
  name: string
  /**
   * æ¸²æŸ“时强制使用的显示名(不含 @)
   *
   * ç”¨äºŽã€ŒåŽ†å²æ¶ˆæ¯ content é‡Œå†™çš„æ˜¯å¤‡æ³¨å / ç¾¤æ˜µç§°ï¼Œä½†æŽ¥æ”¶ç«¯è¦æŒ‰çœŸå®žæ˜µç§°æ¸²æŸ“」的场景;
   * å€™é€‰å¯ä»¥æºå¸¦å¤šç§å­—面量(match ç”¨ï¼‰ï¼Œä½† displayName ç»Ÿä¸€æŒ‡å‘ nickname,
   * è®©æ‰€æœ‰åŽ†å² / æ–°æ¶ˆæ¯çš„ @ éƒ½æ”¶æ•›åˆ°ä¸€è‡´çš„展示
   */
  displayName?: string
  /**
   * æ­§ä¹‰æ ‡è®°ï¼šåŒ name å¯¹åº”多个 userId æ—¶ä¸º true
   *
   * parser ä»ä¼šæŒ‰ name å‘½ä¸­ï¼ˆæœ€é•¿ä¼˜å…ˆï¼‰ï¼Œä½†å‘½ä¸­åŽæ•´æ®µåƒæˆæ™®é€šæ–‡æœ¬â€”—
   * é¿å…ã€Œ@张三」被剔除后,短前缀候选「@张」抢匹配错绑
   */
  ambiguous?: boolean
}
/** URL é”šå®šæ­£åˆ™ï¼›ç»ˆæ­¢äºŽç©ºç™½ã€ä¸­æ–‡ã€@(避免吞掉下一个 mention)、< > " '(防 HTML / å¼•号串入);y æ ‡å¿—èµ° sticky åŒ¹é…ï¼Œçœ text.slice */
const URL_STICKY_REGEX = /(https?:\/\/[^\s一-é¾¥@<>"']+|www\.[^\s一-é¾¥@<>"']+)/iy
/** URL æœ«å°¾å¸¸è§æ ‡ç‚¹å‰”除,避免吞掉句末中英文标点 */
const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/
/** æœ€çŸ­ URL:`www.ab` / `http:/` è¿™ç§å­¤æ®µä¸ç®—链接 */
const URL_MIN_LENGTH = 6
/**
 * æ–‡æœ¬æ°”泡 content æ‹†æ®µï¼šmention æ®µæŒ‰å€™é€‰æœ€é•¿å‰ç¼€åŒ¹é…ï¼ŒURL æ®µèµ°é”šå®šæ­£åˆ™ï¼Œå‰©ä½™å½’ text
 *
 * mentions ä¸ä¼ æˆ–匹配不上时,@xxx é€€åŒ–为普通 text;解析与渲染解耦,工具函数本身不依赖 store
 */
export function parseTextSegments(text: string, mentions: MentionCandidate[] = []): TipSegment[] {
  if (!text) {
    return []
  }
  // ã€Œ@张三丰」不能被「@张三」截胡,候选按 name é•¿åº¦å€’序
  const sortedMentions =
    mentions.length > 1 ? mentions.toSorted((a, b) => b.name.length - a.name.length) : mentions
  const out: TipSegment[] = []
  let buffer = ''
  const flush = () => {
    if (buffer) {
      out.push(tipText(buffer))
      buffer = ''
    }
  }
  let i = 0
  while (i < text.length) {
    if (text[i] === '@' && sortedMentions.length > 0) {
      const matched = sortedMentions.find((m) => m.name && text.startsWith(m.name, i + 1))
      if (matched) {
        if (matched.ambiguous) {
          // åŒå­—面量对应多 userId,无法判定意图;整段累入 buffer è®©çŸ­å‰ç¼€å€™é€‰æ²¡æœºä¼šå†æ‰«è¿™æ®µ
          buffer += '@' + matched.name
        } else {
          flush()
          // æ¸²æŸ“统一走 displayName(即真实昵称),让历史 content é‡Œæ®‹ç•™çš„备注 / ç¾¤æ˜µç§°ä¹Ÿæ”¶æ•›åˆ° nickname
          out.push(tipMention(matched.userId, '@' + (matched.displayName || matched.name)))
        }
        i += 1 + matched.name.length
        continue
      }
    }
    const head = text[i]
    if (head === 'h' || head === 'H' || head === 'w' || head === 'W') {
      URL_STICKY_REGEX.lastIndex = i
      const linkMatch = URL_STICKY_REGEX.exec(text)
      if (linkMatch) {
        const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, '')
        if (urlText.length >= URL_MIN_LENGTH) {
          flush()
          const href = /^https?:\/\//i.test(urlText) ? urlText : `https://${urlText}`
          out.push(tipLink(href, urlText))
          i += urlText.length
          continue
        }
      }
    }
    buffer += text[i]
    i += 1
  }
  flush()
  return out
}
// ==================== å¼•用消息 ====================
/** å¼•用容器:5 ç§æ™®é€šæ¶ˆæ¯(TEXT / IMAGE / FILE / VOICE / VIDEO)都可携带 quote */
interface Quotable {
  quote?: QuoteMessage
}
// ==================== æ¶ˆæ¯ payload ====================
/** æ–‡æœ¬æ¶ˆæ¯ payload(对齐后端 TextMessage) */
export interface TextMessage extends Quotable {
  content: string
}
/** å›¾ç‰‡æ¶ˆæ¯ payload(对齐后端 ImageMessage) */
export interface ImageMessage extends Quotable {
  url: string
  /** ç¼©ç•¥å›¾ URL */
  thumbnailUrl?: string
  /** å›¾ç‰‡å®½åº¦ */
  width?: number
  /** å›¾ç‰‡é«˜åº¦ */
  height?: number
  /** æ–‡ä»¶å¤§å°ï¼ˆå­—节) */
  size?: number
}
/** è¯­éŸ³æ¶ˆæ¯ payload(对齐后端 AudioMessage;ImContentType ä¿ç•™ VOICE å‘½åï¼‰ */
export interface AudioMessage extends Quotable {
  url: string
  /** æ—¶é•¿ï¼ˆç§’) */
  duration: number
  /** æ–‡ä»¶å¤§å°ï¼ˆå­—节) */
  size?: number
}
/** æ–‡ä»¶æ¶ˆæ¯ payload(对齐后端 FileMessage) */
export interface FileMessage extends Quotable {
  url: string
  name: string
  size: number
  /** MIME ç±»åž‹ */
  type?: string
}
/** è§†é¢‘消息 payload(对齐后端 VideoMessage;暂未接入渲染) */
export interface VideoMessage extends Quotable {
  url: string
  /** å°é¢ URL */
  coverUrl?: string
  /** æ—¶é•¿ï¼ˆç§’) */
  duration?: number
  width?: number
  height?: number
  /** æ–‡ä»¶å¤§å°ï¼ˆå­—节) */
  size?: number
}
/**
 * åç‰‡æ¶ˆæ¯ payload(对齐后端 CardMessage)
 *
 * æŒ‰ targetType åŒºåˆ†ç”¨æˆ·åç‰‡ / ç¾¤åç‰‡ï¼š
 * - PRIVATE:targetId = ç”¨æˆ·ç¼–号,name = ç”¨æˆ·æ˜µç§°ï¼ˆå–真实昵称,非备注)
 * - GROUP:targetId = ç¾¤ç¼–号,name = ç¾¤åï¼Œå¯å¸¦ memberCount
 */
export interface CardMessage extends Quotable {
  /** åç‰‡å¯¹è±¡ç±»åž‹ */
  targetType: ImConversationTypeValue
  /** ç›®æ ‡å¯¹è±¡ç¼–号:PRIVATE æ—¶ = userId;GROUP æ—¶ = groupId */
  targetId: number
  /** æ˜¾ç¤ºåå¿«ç…§ï¼šPRIVATE æ—¶ = ç”¨æˆ·æ˜µç§°ï¼›GROUP æ—¶ = ç¾¤å */
  name: string
  /** å¤´åƒï¼ˆå¿«ç…§ï¼‰ */
  avatar?: string
  /** ç¾¤æˆå‘˜æ•°ï¼ˆä»… targetType = GROUP;接收端展示「N äººç¾¤èŠã€ï¼‰ */
  memberCount?: number
}
/**
 * åç‰‡è½¬å‘的源对象(RecommendCardDialog å…¥å‚);字段与 CardMessage 1:1,无 quote
 */
export type CardTarget = Omit<CardMessage, 'quote'>
/** ç”¨æˆ·å¯¹è±¡ â†’ ç”¨æˆ·åç‰‡æºï¼ˆPRIVATE);缺 id è¿”回 null è®©è°ƒç”¨æ–¹ v-bind ç»‘定为不渲染 */
export function toUserCardTarget(user: null | undefined | User): CardTarget | null {
  if (!user?.id) {
    return null
  }
  return {
    targetType: ImConversationType.PRIVATE,
    targetId: user.id,
    name: user.nickname || '',
    avatar: user.avatar
  }
}
/** ç¾¤å¯¹è±¡ â†’ ç¾¤åç‰‡æºï¼ˆGROUP);缺 id è¿”回 null */
export function toGroupCardTarget(group: GroupLite | null | undefined): CardTarget | null {
  if (!group?.id) {
    return null
  }
  return {
    targetType: ImConversationType.GROUP,
    targetId: group.id,
    name: group.name || '',
    avatar: group.showImage || group.showImageThumb,
    memberCount: group.memberCount
  }
}
/**
 * åç‰‡æ ‡ç­¾ + å›¾æ ‡ï¼ˆæŒ‰ targetType äºŒåˆ†ï¼‰ï¼Œç»Ÿä¸€èŠå¤©æ°”泡 / å¼•用预览 / åŽ†å²æ‘˜è¦ / åŽå°é¢„览的文案与图标
 *
 * ç¼ºå¤± / éžæ³• targetType èµ°ã€Œä¸ªäººåç‰‡ã€å…œåº•,避免老消息或脏数据导致 UI ç©ºç™½
 */
export function getCardLabelInfo(card: null | undefined | { targetType?: number }): {
  icon: string
  label: string
} {
  if (card?.targetType === ImConversationType.GROUP) {
    return { label: '群名片', icon: 'ant-design:usergroup-outlined' }
  }
  return { label: '个人名片', icon: 'ant-design:user-outlined' }
}
/** è¡¨æƒ…消息 payload(对齐后端 FaceMessage;Unicode emoji ä»èµ° TEXT,本类型只承载贴图 / è‡ªå®šä¹‰è¡¨æƒ…包) */
export interface FaceMessage extends Quotable {
  /** è¡¨æƒ…图 URL */
  url: string
  /** æ¸²æŸ“宽度(像素),避免布局抖动;可选,缺失时调用方走 CSS max-w å…œåº• */
  width?: number
  /** æ¸²æŸ“高度(像素),可选 */
  height?: number
  /** è¡¨æƒ…名(系统包通常有,个人表情包通常无) */
  name?: string
}
/** åˆå¹¶è½¬å‘的单条内嵌消息快照(对齐后端 MergeMessage.Item) */
export interface MergeMessageItem {
  /** åŽŸæ¶ˆæ¯ç¼–å·ï¼›ä»…åšæº¯æºæ ‡è¯† */
  messageId: number
  /** å‘送人编号 */
  senderId: number
  /** å‘送人昵称快照;对端可能不在原会话里,无法实时查到 */
  senderNickname: string
  /** å‘送人头像快照 */
  senderAvatar?: string
  /** å†…容类型,对齐 ImContentType */
  type: number
  /** åŽŸæ¶ˆæ¯ content(JSON);嵌套合并消息时仍按本结构层层展开 */
  content: string
  /** å‘送时间戳(毫秒) */
  sendTime: number
}
/** åˆå¹¶è½¬å‘消息 payload(对齐后端 MergeMessage) */
export interface MergeMessage {
  title: string // åˆå¹¶æ ‡é¢˜ï¼›ä¾‹ï¼šã€Œå¼ ä¸‰å’ŒæŽå››çš„聊天记录」「群聊的聊天记录」
  messages: MergeMessageItem[] // å†…嵌的完整消息快照
}
/** é¢‘道素材消息 payload(对齐后端 MaterialMessage) */
export interface MaterialMessage {
  materialId?: number
  channelId?: number // é¢‘道编号;转发后渲染卡片底部的频道头像 + åç§°
  title?: string
  coverUrl?: string
  summary?: string
  url?: string // è·³è½¬é“¾æŽ¥ï¼›ä¸ºç©ºæ—¶ç‚¹å‡»åœ¨å®¢æˆ·ç«¯å†…置详情页按 materialId æ‹‰ content æ¸²æŸ“;非空则跳 url
}
// ==================== åˆå¹¶è½¬å‘ payload æž„造 ====================
/** å•个发送人的快照昵称 / å¤´åƒ */
interface SenderSnapshot {
  nickname: string
  avatar: string
}
/**
 * ä¸€æ¬¡æ€§æž„造 senderId â†’ SenderSnapshot æ˜ å°„;避免 N æ¡æ¶ˆæ¯é€æ¡ find ç¾¤æˆå‘˜
 *
 * ç¾¤èŠä»Ž group.members ä¸€æ¬¡æ€§ indexBy;私聊只需 self + friend;外加全体 senderId ä¸Šåš friend å…œåº•
 */
function buildSenderSnapshotMap(
  senderIds: number[],
  conversation: Conversation
): Map<number, SenderSnapshot> {
  const userStore = useUserStore()
  const friendStore = useFriendStore()
  const selfUserId = getCurrentUserId()
  const result = new Map<number, SenderSnapshot>()
  let groupMembers: Map<number, { avatar?: string; nickname: string; }> | null = null
  if (conversation.type === ImConversationType.GROUP) {
    const group = useGroupStore().getGroup(conversation.targetId)
    groupMembers = new Map((group?.members || []).map((m) => [m.userId, m]))
  }
  for (const senderId of senderIds) {
    if (result.has(senderId)) {
      continue
    }
    if (senderId === selfUserId) {
      result.set(senderId, {
        nickname: userStore.userInfo?.nickname || String(senderId),
        avatar: userStore.userInfo?.avatar || ''
      })
      continue
    }
    const member = groupMembers?.get(senderId)
    if (member?.nickname) {
      result.set(senderId, { nickname: member.nickname, avatar: member.avatar || '' })
      continue
    }
    const friend = friendStore.getFriend(senderId)
    result.set(senderId, {
      nickname: friend?.nickname || String(senderId),
      avatar: friend?.avatar || ''
    })
  }
  return result
}
/** æŠŠå•条 Message è½¬æˆ MergeMessageItem å¿«ç…§ï¼›å‰¥ç¦» quote é˜²å¼•用穿透 */
function mapMessageToMergeItem(
  message: Message,
  senderSnapshots: Map<number, SenderSnapshot>
): MergeMessageItem {
  const snapshot = senderSnapshots.get(message.senderId)
  return {
    messageId: message.id || 0,
    senderId: message.senderId,
    senderNickname: snapshot?.nickname ?? String(message.senderId),
    senderAvatar: snapshot?.avatar ?? '',
    type: message.type,
    content: removeQuotePayload(message.content),
    sendTime: message.sendTime
  }
}
/** åˆå¹¶è½¬å‘标题:私聊「{对方} å’Œ {自己} çš„聊天记录」;群聊「{群名} çš„聊天记录」 */
export function buildMergeTitle(conversation: Conversation): string {
  if (conversation.type === ImConversationType.GROUP) {
    return `${conversation.name || '群聊'} çš„聊天记录`
  }
  const myName = useUserStore().userInfo?.nickname || '我'
  return `${conversation.name || '对方'} å’Œ ${myName} çš„聊天记录`
}
/** æŠŠä¸€ç»„ Message æ‰“包成 MergeMessage payload;调用方负责按 sendTime æŽ’序后传入 */
export function buildMergeMessagePayload(
  messages: Message[],
  conversation: Conversation
): MergeMessage {
  const senderIds = messages.map((m) => m.senderId)
  const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation)
  return {
    title: buildMergeTitle(conversation),
    messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots))
  }
}
/** ã€Œæ·»åŠ åˆ°è¡¨æƒ…ã€çš„å¯å‘èµ·æºï¼šFACE / IMAGE éƒ½å…è®¸ï¼ˆGIF å›¾ç‰‡ä¹Ÿå¸¸è¢«æ”¶è—ï¼‰ */
export interface AddableFacePayload {
  url: string
  width: number
  height: number
  name?: string
}
/**
 * ä»Žæ¶ˆæ¯æŠ½å–「添加到表情」的 payload;当前内容类型不可添加返回 null
 *
 * è°ƒç”¨æ–¹ï¼ˆMessageItem çš„右键菜单)按 nullable å†³å®šæ˜¯å¦å±•示「添加到表情」入口
 */
export function extractAddableFace(message: Message): AddableFacePayload | null {
  if (message.type === ImContentType.FACE) {
    const face = parseMessage<FaceMessage>(message.content)
    if (!face?.url) {
      return null
    }
    return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name }
  }
  if (message.type === ImContentType.IMAGE) {
    const image = parseMessage<ImageMessage>(message.content)
    if (!image?.url) {
      return null
    }
    return { url: image.url, width: image.width || 200, height: image.height || 200 }
  }
  return null
}
/** è§£æžæ¶ˆæ¯ content(JSON å­—符串)为指定 payload,非法 JSON è¿”回 null */
export const parseMessage = <T>(content: string): null | T => {
  try {
    return JSON.parse(content) as T
  } catch {
    return null
  }
}
/** åºåˆ—化消息 payload ä¸º content JSON å­—符串;与 parseMessage å¯¹ç§° */
export const serializeMessage = <T>(payload: T): string => JSON.stringify(payload)
/** `URL.createObjectURL(file)` ç”Ÿæˆçš„ URL å‰ç¼€ï¼›å ä½ / revoke / é‡ä¼ æ—§å€¼è¯†åˆ«å…±ç”¨ */
export const BLOB_URL_PREFIX = 'blob:'
/**
 * åª’体 payload é‡Œå¯èƒ½åŒ…含 blob URL çš„字段(图片/文件/视频/语音都对齐这套 url å­—段命名)
 *
 * è·Ÿéš ImageMessage / VideoMessage / FileMessage / AudioMessage interface å®šä¹‰åŒæ­¥ï¼š
 * - url:主体资源(占位时是 blob URL,ack åŽæ˜¯çœŸå®ž URL)
 * - coverUrl:视频封面(commit åŽæ˜¯çœŸå®ž URL;占位阶段不设以避免传 blob å½“ poster åœ¨éƒ¨åˆ†æµè§ˆå™¨é€€åŒ–)
 * - thumbnailUrl:图片缩略图(当前未占位时使用 blob,预留)
 */
const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const
/**
 * é‡Šæ”¾ content ä¸­åª’体 payload å­—段上的 blob URL å†…存映射
 *
 * ä»…扫描 url / coverUrl / thumbnailUrl ä¸‰ä¸ªå·²çŸ¥å­—段,避免 regex å…¨æ–‡ grep è¯¯ä¼¤ quote.content é‡ŒåµŒå¥—的同名 blob URL。
 * ä»…对当前 document å†…创建的 blob URL æœ‰æ•ˆï¼›IndexedDB æ¢å¤å‡ºæ¥çš„æ—§ blob URL å·²éšæ—§ document å¤±æ•ˆï¼Œè°ƒå®ƒæ— å®³ä½†æ— æ„ä¹‰
 */
export const revokeBlobUrlsInContent = (content: string): void => {
  if (!content || !content.includes(BLOB_URL_PREFIX)) {
    return
  }
  const payload = parseMessage<Record<string, unknown>>(content)
  if (!payload) {
    return
  }
  for (const field of MEDIA_BLOB_URL_FIELDS) {
    const value = payload[field]
    if (typeof value === 'string' && value.startsWith(BLOB_URL_PREFIX)) {
      URL.revokeObjectURL(value)
    }
  }
}
// ==================== å¼•用消息 helper ====================
/** æŠŠ quote åˆè¿› payload(序列化前调用);quote ç¼ºå¤±æ—¶åŽŸæ ·è¿”å›ž */
export const withQuotePayload = <T extends Quotable>(payload: T, quote?: QuoteMessage): T => {
  if (!quote) {
    return payload
  }
  return { ...payload, quote }
}
/**
 * ä»Ž content JSON å­—符串里清掉 quote å­—段
 *
 * å®¢æˆ·ç«¯ä¹è§‚渲染构造 quote æ—¶è°ƒç”¨,避免"回复一条带引用的消息"造成 quote åµŒå¥—滚雪球;
 * ä¸ŽåŽç«¯ ImMessageUtils.removeQuote å¯¹é½
 */
export const removeQuotePayload = (content: string): string => {
  if (!content || !content.includes('"quote"')) {
    return content
  }
  const parsed = parseMessage<Record<string, unknown>>(content)
  if (!parsed || !('quote' in parsed)) {
    return content
  }
  delete parsed.quote
  return JSON.stringify(parsed)
}
/** ç”± Message æ´¾ç”Ÿ QuoteMessage ç”¨äºŽä¹è§‚渲染;ack åŽä¼šè¢«æœåŠ¡ç«¯æƒå¨ç‰ˆæœ¬è¦†ç›– */
export const buildQuoteFromMessage = (message: Message): QuoteMessage => {
  return {
    messageId: message.id || 0,
    senderId: message.senderId,
    type: message.type,
    content: removeQuotePayload(message.content)
  }
}
/** ä»Žå·²åºåˆ—化 message.content ä¸­è§£å‡º quote;非 JSON / æ—  quote è¿”回 null */
export const getQuoteFromMessage = (content: string): null | QuoteMessage => {
  // é•¿ä¼šè¯æ¯æ¡æ¶ˆæ¯æ¸²æŸ“都走 quote computed,非引用消息字符串预扫直接返回,免一次 JSON.parse
  if (!content || !content.includes('"quote"')) {
    return null
  }
  const parsed = parseMessage<Quotable>(content)
  return parsed?.quote ?? null
}
// ==================== æ’¤å›ž ====================
/**
 * ä»ŽåŽç«¯ä¸‹å‘的撤回 RecallMessage content ä¸­è§£æžå‡ºè¢«æ’¤å›žçš„原消息 id
 * content å½¢å¦‚ `{"messageId": 123}`,若不含 messageId åˆ™è¿”回 0(表示这条不是撤回消息)
 */
export const parseRecallMessageId = (content: string): number => {
  try {
    const parsed = JSON.parse(content)
    return parsed?.messageId == null ? 0 : Number(parsed.messageId)
  } catch {
    return 0
  }
}
// ==================== æ–°æ¶ˆæ¯æç¤ºéŸ³ ====================
import tipAudioUrl from '#/assets/audio/im/message-tip.mp3'
/**
 * æ–°æ¶ˆæ¯æç¤ºéŸ³ï¼Œå¸¦ 1 ç§’节流:短时间内多条消息只响一次,避免疲劳轰炸。
 *
 * æµè§ˆå™¨è‡ªåŠ¨æ’­æ”¾ç­–ç•¥è¦æ±‚é¡µé¢æœ‰è¿‡ç”¨æˆ·äº¤äº’ï¼›è‹¥æ— æ³•æ’­æ”¾ä¼šé™é»˜å¤±è´¥ã€‚
 */
let __lastPlayAudioTipAt = 0
let __tipAudio: HTMLAudioElement | null = null
export const playAudioTip = () => {
  const now = Date.now()
  if (now - __lastPlayAudioTipAt < 1000) {
    return
  }
  __lastPlayAudioTipAt = now
  try {
    if (!__tipAudio) {
      __tipAudio = new Audio(tipAudioUrl)
      __tipAudio.preload = 'auto'
    }
    __tipAudio.currentTime = 0
    const playPromise = __tipAudio.play()
    if (playPromise && typeof playPromise.catch === 'function') {
      playPromise.catch((error) => console.debug('[IM] playAudioTip å¤±è´¥', error))
    }
  } catch (error) {
    console.debug('[IM] playAudioTip å¤±è´¥', error)
  }
}
// ==================== æ–‡ä»¶å›¾æ ‡ ====================
/**
 * æŒ‰æ–‡ä»¶æ‰©å±•名挑文件图标 + é¢œè‰²ï¼Œå¯¹é½å¾®ä¿¡è§‚感
 *
 * message-item.vue(主气泡)和 reply-preview.vue(引用预览)共用同一份映射,避免视觉两处不一致
 */
export function getFileIconInfo(filename: string | undefined): { color: string; icon: string; } {
  const ext = (filename || '').split('.').pop()?.toLowerCase() || ''
  if (ext === 'pdf') {
    return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }
  }
  if (['doc', 'docx'].includes(ext)) {
    return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }
  }
  if (['xls', 'xlsx'].includes(ext)) {
    return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }
  }
  if (['ppt', 'pptx'].includes(ext)) {
    return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }
  }
  if (['7z', 'gz', 'rar', 'tar', 'zip'].includes(ext)) {
    return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }
  }
  if (['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(ext)) {
    return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }
  }
  if (['avi', 'flv', 'mkv', 'mov', 'mp4', 'wmv'].includes(ext)) {
    return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }
  }
  if (['aac', 'flac', 'mp3', 'ogg', 'wav'].includes(ext)) {
    return { icon: 'ant-design:audio-filled', color: '#9c27b0' }
  }
  if (['json', 'log', 'md', 'txt', 'xml'].includes(ext)) {
    return { icon: 'ant-design:file-text-filled', color: '#909399' }
  }
  return { icon: 'ant-design:file-filled', color: '#909399' }
}
// ==================== ç®¡ç†åŽå°å±•示工具 ====================
/** è¯¦æƒ…弹窗里把 content JSON ç¾ŽåŒ–成 2 ç¼©è¿› */
export const formatJson = (content?: string): string => {
  if (!content) return ''
  try {
    return JSON.stringify(JSON.parse(content), null, 2)
  } catch {
    return content
  }
}
// ==================== ç¾¤å¹¿æ’­äº‹ä»¶ payload + æ–‡æ¡ˆ ====================
// ç¾¤å¹¿æ’­äº‹ä»¶ payload;对齐后端 GroupNotificationMessage å­ç±»èšåˆå­—段
export type GroupNotificationPayload = {
  addSource?: number
  // å…¨ç¾¤å°ç¦
  banned?: boolean
  displayUserName?: string
  // è‡ªç”±è¿›ç¾¤äº‹ä»¶
  entrantUserId?: number
  memberUserIds?: number[]
  // PIN äº‹ä»¶æºå¸¦çš„被置顶消息展示数据
  message?: {
    atUserIds?: number[]
    content: string
    groupId: number
    id: number
    receiverUserIds?: number[]
    senderId: number
    sendTime: string
    type: number
  }
  messageId?: number
  // ç¦è¨€äº‹ä»¶
  mutedUserId?: number
  muteEndTime?: string
  newAvatar?: string
  newJoinApproval?: boolean
  newName?: string
  newNotice?: string
  newOwnerUserId?: number
  oldAvatar?: string
  oldJoinApproval?: boolean
  oldName?: string
  oldNotice?: string
  operatorUserId?: number
}
/**
 * ç¾¤å¹¿æ’­äº‹ä»¶ segments
 *
 * resolveName ç”±è°ƒç”¨æ–¹æ³¨å…¥ï¼ˆé»˜è®¤åœºæ™¯ä¼  getSenderDisplayName);
 * operatorNameOverride ä»…覆盖 operator æ®µæ–‡æ¡ˆï¼Œmention userId ä»ç”¨ payload.operatorUserId
 */
export function resolveGroupNotificationSegments(
  message: { content?: string; targetId?: number; type?: number; },
  resolveName: (userId: number) => string,
  operatorNameOverride?: string
): TipSegment[] {
  let payload: GroupNotificationPayload
  try {
    payload = JSON.parse(message.content || '{}')
  } catch {
    return []
  }
  // ENTER ä¸»è¯­æ˜¯ entrant è€Œéž operator,独立处理;其它 case éƒ½ä»¥ operatorUserId ä¸ºä¸»è¯­
  if (message.type === ImContentType.GROUP_MEMBER_ENTER) {
    const entrantId = payload.entrantUserId ?? payload.operatorUserId
    return entrantId ? [tipMention(entrantId, resolveName(entrantId)), tipText(' åŠ å…¥äº†ç¾¤èŠ')] : []
  }
  if (!payload.operatorUserId) {
    return []
  }
  const operatorSegment = tipMention(
    payload.operatorUserId,
    operatorNameOverride ?? resolveName(payload.operatorUserId)
  )
  const memberSegments = joinMentionSegments(payload.memberUserIds || [], '、', resolveName)
  switch (message.type) {
    case ImContentType.GROUP_ADMIN_ADD: {
      return [operatorSegment, tipText(' å°† '), ...memberSegments, tipText(' è®¾ä¸ºç®¡ç†å‘˜')]
    }
    case ImContentType.GROUP_ADMIN_REMOVE: {
      return [operatorSegment, tipText(' æ’¤é”€äº† '), ...memberSegments, tipText(' çš„管理员身份')]
    }
    case ImContentType.GROUP_BANNED: {
      return [operatorSegment, tipText(payload.banned ? ' å°ç¦äº†è¯¥ç¾¤' : ' è§£å°äº†è¯¥ç¾¤')]
    }
    case ImContentType.GROUP_CANCEL_MUTED: {
      return [operatorSegment, tipText(' å…³é—­äº†å…¨ç¾¤ç¦è¨€')]
    }
    case ImContentType.GROUP_CREATE: {
      return [operatorSegment, tipText(' åˆ›å»ºäº†ç¾¤èŠ')]
    }
    case ImContentType.GROUP_DISSOLVE: {
      return [operatorSegment, tipText(' è§£æ•£äº†ç¾¤èŠ')]
    }
    case ImContentType.GROUP_INFO_UPDATE: {
      return payload.newAvatar
        ? [operatorSegment, tipText(' æ›´æ¢äº†ç¾¤å¤´åƒ')]
        : [operatorSegment, tipText(' æ›´æ–°äº†ç¾¤ä¿¡æ¯')]
    }
    case ImContentType.GROUP_MEMBER_CANCEL_MUTED: {
      return payload.mutedUserId
        ? [
            operatorSegment,
            tipText(' è§£é™¤äº† '),
            tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)),
            tipText(' çš„禁言')
          ]
        : []
    }
    case ImContentType.GROUP_MEMBER_INVITE: {
      return [operatorSegment, tipText(' é‚€è¯· '), ...memberSegments, tipText(' åŠ å…¥ç¾¤èŠ')]
    }
    case ImContentType.GROUP_MEMBER_KICK: {
      return [operatorSegment, tipText(' ç§»å‡ºäº† '), ...memberSegments]
    }
    case ImContentType.GROUP_MEMBER_MUTED: {
      return payload.mutedUserId
        ? [
            operatorSegment,
            tipText(' å°† '),
            tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)),
            tipText(' ç¦è¨€')
          ]
        : []
    }
    case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
      return [operatorSegment, tipText(` ä¿®æ”¹ç¾¤æ˜µç§°ä¸º "${payload.displayUserName ?? ''}"`)]
    }
    case ImContentType.GROUP_MEMBER_QUIT: {
      return [operatorSegment, tipText(' é€€å‡ºäº†ç¾¤èŠ')]
    }
    case ImContentType.GROUP_MESSAGE_PIN: {
      return [operatorSegment, tipText(' ç½®é¡¶äº†ä¸€æ¡æ¶ˆæ¯')]
    }
    case ImContentType.GROUP_MESSAGE_UNPIN: {
      return [operatorSegment, tipText(' å–消了一条置顶消息')]
    }
    case ImContentType.GROUP_MUTED: {
      return [operatorSegment, tipText(' å¼€å¯äº†å…¨ç¾¤ç¦è¨€')]
    }
    case ImContentType.GROUP_NAME_UPDATE: {
      return [operatorSegment, tipText(` å°†ç¾¤åä¿®æ”¹ä¸º "${payload.newName ?? ''}"`)]
    }
    case ImContentType.GROUP_NOTICE_UPDATE: {
      return [operatorSegment, tipText(' æ›´æ–°äº†ç¾¤å…¬å‘Š')]
    }
    case ImContentType.GROUP_OWNER_TRANSFER: {
      return payload.newOwnerUserId
        ? [
            operatorSegment,
            tipText(' å·²å°†ç¾¤ä¸»è½¬è®©ç»™ '),
            tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId))
          ]
        : []
    }
    default: {
      return []
    }
  }
}
/** ç¾¤å¹¿æ’­äº‹ä»¶ä¸­æ–‡æ–‡æ¡ˆ */
export function resolveGroupNotificationText(
  message: { content?: string; targetId?: number; type?: number; },
  resolveName: (userId: number) => string,
  operatorNameOverride?: string
): string {
  return segmentsToText(
    resolveGroupNotificationSegments(message, resolveName, operatorNameOverride)
  )
}
// ==================== å¥½å‹äº‹ä»¶ ====================
/** ä¼šè¯å†…好友事件 segments */
export function resolveFriendNotificationSegments(message: { type?: number }): TipSegment[] {
  switch (message.type) {
    case ImContentType.FRIEND_ADD: {
      return [tipText('你们已经是好友了,开始聊天吧')]
    }
    case ImContentType.FRIEND_DELETE: {
      return [tipText('你已删除好友')]
    }
    default: {
      return []
    }
  }
}
/** ä¼šè¯å†…好友事件文案:FRIEND_ADD / FRIEND_DELETE æ¸²æŸ“成灰色提示气泡,文案固定不依赖 payload */
export function resolveFriendNotificationText(message: { type?: number }): string {
  return segmentsToText(resolveFriendNotificationSegments(message))
}
// ==================== RTC é€šè¯äº‹ä»¶ ====================
// RTC_CALL_START payload;仅群聊;用于聊天 tip æ–‡æ¡ˆã€Œ{inviter} å‘起了{voice/video}通话」
export type RtcCallStartPayload = {
  conversationType?: number
  inviterAvatar?: string
  inviterNickname?: string
  inviterUserId?: number
  mediaType?: number
  room?: string
}
// RTC_CALL_END payload;私聊准气泡 + ç¾¤èŠã€Œé€šè¯å·²ç»“束 [时长 X]」共用
export type RtcCallEndPayload = {
  conversationType?: number
  durationSeconds?: number
  endReason?: number
  mediaType?: number
  operatorAvatar?: string
  operatorNickname?: string
  operatorUserId?: number
  room?: string
}
/** è§£æž RTC_CALL_START / RTC_CALL_END æ¶ˆæ¯ content;解析失败返回 null */
export function parseRtcCallPayload(
  content?: string
): null | (RtcCallEndPayload & RtcCallStartPayload) {
  return content ? parseMessage<RtcCallEndPayload & RtcCallStartPayload>(content) : null
}
/**
 * ä¼šè¯å†…通话事件 segments(RTC_CALL_START / RTC_CALL_END)
 * <p>
 * ç¾¤èŠä¸¤æ®µå¼ï¼šSTART「{inviter} å‘起了语音通话」+ END「语音通话已经结束」
 * <p>
 * ç§èŠæ°”泡走 {@link resolveRtcCallPrivateBubbleText}
 */
export function resolveRtcCallTipSegments(message: {
  content?: string
  selfSend?: boolean
  type?: number
}): TipSegment[] {
  const payload = parseRtcCallPayload(message.content)
  if (!payload) {
    return []
  }
  if (message.type === ImContentType.RTC_CALL_START) {
    return payload.inviterUserId
      ? [
          tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)),
          tipText(' å‘起了语音通话')
        ]
      : []
  }
  if (message.type === ImContentType.RTC_CALL_END) {
    return [tipText('语音通话已经结束')]
  }
  return []
}
/** å– RTC é€šè¯å‘起人展示名;昵称为空时回退「用户 {id}」 */
function resolveRtcInviterLabel(payload: RtcCallStartPayload): string {
  return payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}`
}
/**
 * ä¼šè¯åˆ—表最后一条预览文案(RTC_CALL_START / RTC_CALL_END)
 * <p>
 * ç§èŠï¼šSTART / END éƒ½å±•示「[语音通话]」,对齐 [图片] / [语音] é£Žæ ¼
 * <p>
 * ç¾¤èŠï¼šSTART「{inviter} å‘起了语音通话」、END「语音通话已经结束」,与聊天 tip åŒæº
 */
export function resolveRtcCallLastContent(
  message: { content?: string; type?: number; },
  conversationType: number
): string {
  if (conversationType === ImConversationType.PRIVATE) {
    return '[语音通话]'
  }
  if (message.type === ImContentType.RTC_CALL_END) {
    return '语音通话已经结束'
  }
  if (message.type === ImContentType.RTC_CALL_START) {
    const payload = parseRtcCallPayload(message.content)
    if (payload) {
      return `${resolveRtcInviterLabel(payload)} å‘起了语音通话`
    }
  }
  return ''
}
/**
 * ç§èŠ RTC_CALL_END æ°”泡内文案;按 operatorUserId æ˜¯ä¸æ˜¯è‡ªå·±æ¸²æŸ“两端不同文案(对齐微信)
 * <p>
 * æ–‡æ¡ˆè§„则:
 *   HANGUP duration > 0 â†’ ã€Œé€šè¯æ—¶é•¿ N」(双方一致)
 *   HANGUP duration â‰¤ 0 â†’ ã€Œé€šè¯ä¸­æ–­ã€ï¼ˆæœªæŽ¥é€šçš„兜底)
 *   CANCEL â†’ æ“ä½œè€…「已取消」/ å¦ä¸€æ–¹ã€Œå¯¹æ–¹å·²å–消」
 *   REJECT â†’ æ“ä½œè€…「已拒绝」/ å¦ä¸€æ–¹ã€Œå¯¹æ–¹å·²æ‹’绝」
 *   BUSY â†’ æ“ä½œè€…「忙线未接听」/ å¦ä¸€æ–¹ã€Œå¯¹æ–¹å¿™çº¿ä¸­ã€
 *   NO_ANSWER â†’ æ“ä½œè€…「未接听」/ å¦ä¸€æ–¹ã€Œå¯¹æ–¹æœªæŽ¥å¬ã€ï¼ˆæŒ¯é“ƒè¶…æ—¶ Job è§¦å‘)
 *   ERROR â†’ ã€Œé€šè¯ä¸­æ–­ [N]」(接通后异常断开;duration > 0 æ—¶å¸¦æ—¶é•¿ï¼‰
 */
export function resolveRtcCallPrivateBubbleText(payload: null | RtcCallEndPayload): string {
  if (!payload) {
    return '通话已结束'
  }
  const duration = payload.durationSeconds ?? 0
  const hasDuration = duration > 0
  const isOperator = payload.operatorUserId === getCurrentUserId()
  switch (payload.endReason) {
    case ImRtcCallEndReason.BUSY: {
      return isOperator ? '忙线未接听' : '对方忙线中'
    }
    case ImRtcCallEndReason.CANCEL: {
      return isOperator ? '已取消' : '对方已取消'
    }
    case ImRtcCallEndReason.ERROR: {
      return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断'
    }
    case ImRtcCallEndReason.HANGUP: {
      return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断'
    }
    case ImRtcCallEndReason.NO_ANSWER: {
      return isOperator ? '未接听' : '对方未接听'
    }
    case ImRtcCallEndReason.REJECT: {
      return isOperator ? '已拒绝' : '对方已拒绝'
    }
    default: {
      return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束'
    }
  }
}
/** ä¼šè¯å†…通话事件文案 */
export function resolveRtcCallTipText(message: {
  content?: string
  selfSend?: boolean
  type?: number
}): string {
  return segmentsToText(resolveRtcCallTipSegments(message))
}
/**
 * RTC_CALL_END ç»“束原因兜底文案;前端 toast / console å…œåº•用
 * <p>
 * ç¼º operator ä¿¡æ¯ï¼ˆåŒæ­¥å“åº” + WS push å…œåº•场景)时的通用文案;细分文案(按发送方视角)走 {@link resolveRtcCallPrivateBubbleText}
 */
export function resolveCallEndReasonText(reason: number | undefined): string {
  switch (reason) {
    case ImRtcCallEndReason.BUSY: {
      return '对方忙线中'
    }
    case ImRtcCallEndReason.CANCEL: {
      return '对方已取消'
    }
    case ImRtcCallEndReason.ERROR: {
      return '通话异常'
    }
    case ImRtcCallEndReason.HANGUP: {
      return '通话已结束'
    }
    case ImRtcCallEndReason.REJECT: {
      return '对方已拒绝'
    }
    default: {
      return '通话已断开'
    }
  }
}