import { generateUUID } from '@/utils' import { useUserStore } from '@/store/modules/user' import { ImRtcCallEndReason, ImConversationType, ImMessageType, type ImConversationTypeValue } from './constants' import { getCurrentUserId } from './storage' import { formatCallDuration } from './time' import { useFriendStore } from '../home/store/friendStore' import { useGroupStore } from '../home/store/groupStore' import type { Conversation, Message, User, GroupLite } from '../home/types' // ==================================================================== // IM 消息 content 编解码 & 展示工具 // ==================================================================== // 约定:消息的 content 字段统一存 JSON 字符串,字段名、结构对齐后端 // cn.iocoder.yudao.module.im.service.websocket.dto.message.* 下的 DTO。 // 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage, // 序列化直接 JSON.stringify(payload)。 // ==================================================================== // ==================== 客户端 ID ==================== /** 生成客户端消息 ID(纯 UUID),用于前端去重 & ACK 回写 */ export const generateClientMessageId = (): string => { return generateUUID() } // ==================== 文本片段(tip 文案 + TEXT 气泡共用) ==================== // 既用于灰条 tip("XX 邀请 YY 加入群聊"),也用于 TEXT 气泡正文(@xxx 高亮 + URL 自动识别)。 // mention 段携带 userId 用于挂点击弹 UserInfoCard;link 段携带 href 用于 跳转; // text 段是纯文本兜底。渲染层(TipSegments.vue)按 type 分发统一处理。 export type TipSegment = | { type: 'text'; text: string } | { type: 'mention'; userId: number; text: string } | { type: 'link'; href: string; text: string } 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].sort((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 } // ==================== 引用消息 ==================== /** 引用消息 payload(对齐后端 QuoteMessage) */ export interface QuoteMessage { messageId: number senderId: number type: number content: string } /** 引用容器: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;ImMessageType 保留 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 /** 用户对象 → 用户名片源(PRIVATE);缺 id 返回 null 让调用方 v-bind 绑定为不渲染 */ export function toUserCardTarget(user: User | null | undefined): 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: { targetType?: number } | null | undefined): { label: string icon: 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 { // TODO @AI:是不是把 messageId 改成 id,和原本的更统一一点? /** 原消息编号;仅做溯源标识 */ messageId: number /** 发送人编号 */ senderId: number /** 发送人昵称快照;对端可能不在原会话里,无法实时查到 */ senderNickname: string /** 发送人头像快照 */ senderAvatar?: string /** 消息类型,对齐 ImMessageType */ type: number /** 原消息 content(JSON);嵌套合并消息时仍按本结构层层展开 */ content: string /** 发送时间戳(毫秒) */ sendTime: number } /** 合并转发消息 payload(对齐后端 MergeMessage) */ export interface MergeMessage { /** 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 */ title: string /** 内嵌的完整消息快照 */ messages: MergeMessageItem[] } /** 频道素材消息 payload(对齐后端 MaterialMessage) */ export interface MaterialMessage { title?: string coverUrl?: string summary?: string /** 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url */ url?: string } // ==================== 合并转发 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 { const userStore = useUserStore() const friendStore = useFriendStore() const selfUserId = getCurrentUserId() const result = new Map() let groupMembers: Map | 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.getUser?.nickname || String(senderId), avatar: userStore.getUser?.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 ): MergeMessageItem { const snapshot = senderSnapshots.get(message.senderId) return { messageId: message.id, 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().getUser?.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 === ImMessageType.FACE) { const face = parseMessage(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 === ImMessageType.IMAGE) { const image = parseMessage(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 = (content: string): T | null => { try { return JSON.parse(content) as T } catch { return null } } /** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ export const serializeMessage = (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>(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 = (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>(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, senderId: message.senderId, type: message.type, content: removeQuotePayload(message.content) } } /** 从已序列化 message.content 中解出 quote;非 JSON / 无 quote 返回 null */ export const getQuoteFromMessage = (content: string): QuoteMessage | null => { // 长会话每条消息渲染都走 quote computed,非引用消息字符串预扫直接返回,免一次 JSON.parse if (!content || !content.includes('"quote"')) { return null } const parsed = parseMessage(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 ? Number(parsed.messageId) : 0 } 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((e) => console.debug('[IM] playAudioTip 失败', e)) } } catch (e) { console.debug('[IM] playAudioTip 失败', e) } } // ==================== 文件图标 ==================== /** * 按文件扩展名挑文件图标 + 颜色,对齐微信观感 * * MessageItem.vue(主气泡)和 ReplyPreview.vue(引用预览)共用同一份映射,避免视觉两处不一致 */ export function getFileIconInfo(filename: string | undefined): { icon: string; color: 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 (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) { return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' } } if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) { return { icon: 'ant-design:file-image-filled', color: '#9c27b0' } } if (['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext)) { return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' } } if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext)) { return { icon: 'ant-design:audio-filled', color: '#9c27b0' } } if (['txt', 'md', 'log', 'json', '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 = { operatorUserId?: number memberUserIds?: number[] newOwnerUserId?: number oldName?: string newName?: string oldNotice?: string newNotice?: string oldAvatar?: string newAvatar?: string displayUserName?: string messageId?: number // 禁言事件 mutedUserId?: number muteEndTime?: string // 全群封禁 banned?: boolean // 自由进群事件 entrantUserId?: number addSource?: number // PIN 事件携带的完整被置顶消息对象 message?: { id: number clientMessageId?: string senderId: number groupId: number type: number content: string status: number sendTime: string atUserIds?: number[] receiverUserIds?: number[] receiptStatus?: number readCount?: number } } /** * 群广播事件 segments * * resolveName 由调用方注入(默认场景传 getSenderDisplayName); * operatorNameOverride 仅覆盖 operator 段文案,mention userId 仍用 payload.operatorUserId */ export function resolveGroupNotificationSegments( message: { type?: number; content?: string; targetId?: 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 === ImMessageType.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 ImMessageType.GROUP_CREATE: return [operatorSegment, tipText(' 创建了群聊')] case ImMessageType.GROUP_NAME_UPDATE: return [operatorSegment, tipText(` 将群名修改为 "${payload.newName ?? ''}"`)] case ImMessageType.GROUP_NOTICE_UPDATE: return [operatorSegment, tipText(' 更新了群公告')] case ImMessageType.GROUP_INFO_UPDATE: return payload.newAvatar ? [operatorSegment, tipText(' 更换了群头像')] : [operatorSegment, tipText(' 更新了群信息')] case ImMessageType.GROUP_DISSOLVE: return [operatorSegment, tipText(' 解散了群聊')] case ImMessageType.GROUP_MEMBER_INVITE: return [operatorSegment, tipText(' 邀请 '), ...memberSegments, tipText(' 加入群聊')] case ImMessageType.GROUP_MEMBER_QUIT: return [operatorSegment, tipText(' 退出了群聊')] case ImMessageType.GROUP_MEMBER_KICK: return [operatorSegment, tipText(' 移出了 '), ...memberSegments] case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE: return [operatorSegment, tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`)] case ImMessageType.GROUP_ADMIN_ADD: return [operatorSegment, tipText(' 将 '), ...memberSegments, tipText(' 设为管理员')] case ImMessageType.GROUP_ADMIN_REMOVE: return [operatorSegment, tipText(' 撤销了 '), ...memberSegments, tipText(' 的管理员身份')] case ImMessageType.GROUP_OWNER_TRANSFER: return payload.newOwnerUserId ? [ operatorSegment, tipText(' 已将群主转让给 '), tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId)) ] : [] case ImMessageType.GROUP_MESSAGE_PIN: return [operatorSegment, tipText(' 置顶了一条消息')] case ImMessageType.GROUP_MESSAGE_UNPIN: return [operatorSegment, tipText(' 取消了一条置顶消息')] case ImMessageType.GROUP_MEMBER_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 将 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), tipText(' 禁言') ] : [] case ImMessageType.GROUP_MEMBER_CANCEL_MUTED: return payload.mutedUserId ? [ operatorSegment, tipText(' 解除了 '), tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)), tipText(' 的禁言') ] : [] case ImMessageType.GROUP_MUTED: return [operatorSegment, tipText(' 开启了全群禁言')] case ImMessageType.GROUP_CANCEL_MUTED: return [operatorSegment, tipText(' 关闭了全群禁言')] case ImMessageType.GROUP_BANNED: return [operatorSegment, tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群')] default: return [] } } /** 群广播事件中文文案 */ export function resolveGroupNotificationText( message: { type?: number; content?: string; targetId?: 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 ImMessageType.FRIEND_ADD: return [tipText('你们已经是好友了,开始聊天吧')] case ImMessageType.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 = { room?: string conversationType?: number mediaType?: number inviterUserId?: number inviterNickname?: string inviterAvatar?: string } // RTC_CALL_END payload;私聊准气泡 + 群聊「通话已结束 [时长 X]」共用 export type RtcCallEndPayload = { room?: string conversationType?: number mediaType?: number endReason?: number durationSeconds?: number operatorUserId?: number operatorNickname?: string operatorAvatar?: string } /** 解析 RTC_CALL_START / RTC_CALL_END 消息 content;解析失败返回 null */ export function parseRtcCallPayload( content?: string ): (RtcCallStartPayload & RtcCallEndPayload) | null { return content ? parseMessage(content) : null } /** * 会话内通话事件 segments(RTC_CALL_START / RTC_CALL_END) *

* 群聊两段式:START「{inviter} 发起了语音通话」+ END「语音通话已经结束」 *

* 私聊气泡走 {@link resolveRtcCallPrivateBubbleText} */ export function resolveRtcCallTipSegments(message: { type?: number content?: string selfSend?: boolean }): TipSegment[] { const payload = parseRtcCallPayload(message.content) if (!payload) { return [] } if (message.type === ImMessageType.RTC_CALL_START) { return payload.inviterUserId ? [tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)), tipText(' 发起了语音通话')] : [] } if (message.type === ImMessageType.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) *

* 私聊:START / END 都展示「[语音通话]」,对齐 [图片] / [语音] 风格 *

* 群聊:START「{inviter} 发起了语音通话」、END「语音通话已经结束」,与聊天 tip 同源 */ export function resolveRtcCallLastContent( message: { type?: number; content?: string }, conversationType: number ): string { if (conversationType === ImConversationType.PRIVATE) { return '[语音通话]' } if (message.type === ImMessageType.RTC_CALL_END) { return '语音通话已经结束' } if (message.type === ImMessageType.RTC_CALL_START) { const payload = parseRtcCallPayload(message.content) if (payload) { return `${resolveRtcInviterLabel(payload)} 发起了语音通话` } } return '' } /** * 私聊 RTC_CALL_END 气泡内文案;按 operatorUserId 是不是自己渲染两端不同文案(对齐微信) *

* 文案规则: * HANGUP duration > 0 → 「通话时长 N」(双方一致) * HANGUP duration ≤ 0 → 「通话中断」(未接通的兜底) * CANCEL → 操作者「已取消」/ 另一方「对方已取消」 * REJECT → 操作者「已拒绝」/ 另一方「对方已拒绝」 * BUSY → 操作者「忙线未接听」/ 另一方「对方忙线中」 * NO_ANSWER → 操作者「未接听」/ 另一方「对方未接听」(振铃超时 Job 触发) * ERROR → 「通话中断 [N]」(接通后异常断开;duration > 0 时带时长) */ export function resolveRtcCallPrivateBubbleText(payload: RtcCallEndPayload | null): string { if (!payload) { return '通话已结束' } const duration = payload.durationSeconds ?? 0 const hasDuration = duration > 0 const isOperator = payload.operatorUserId === getCurrentUserId() switch (payload.endReason) { case ImRtcCallEndReason.HANGUP: return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断' case ImRtcCallEndReason.CANCEL: return isOperator ? '已取消' : '对方已取消' case ImRtcCallEndReason.REJECT: return isOperator ? '已拒绝' : '对方已拒绝' case ImRtcCallEndReason.NO_ANSWER: return isOperator ? '未接听' : '对方未接听' case ImRtcCallEndReason.BUSY: return isOperator ? '忙线未接听' : '对方忙线中' case ImRtcCallEndReason.ERROR: return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断' default: return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束' } } /** 会话内通话事件文案 */ export function resolveRtcCallTipText(message: { type?: number content?: string selfSend?: boolean }): string { return segmentsToText(resolveRtcCallTipSegments(message)) } /** * RTC_CALL_END 结束原因兜底文案;前端 toast / console 兜底用 *

* 缺 operator 信息(同步响应 + WS push 兜底场景)时的通用文案;细分文案(按发送方视角)走 {@link resolveRtcCallPrivateBubbleText} */ export function resolveCallEndReasonText(reason: number | undefined): string { switch (reason) { case ImRtcCallEndReason.REJECT: return '对方已拒绝' case ImRtcCallEndReason.CANCEL: return '对方已取消' case ImRtcCallEndReason.BUSY: return '对方忙线中' case ImRtcCallEndReason.HANGUP: return '通话已结束' case ImRtcCallEndReason.ERROR: return '通话异常' default: return '通话已断开' } }