zhangwencui
6 天以前 f35825c1922149df154e3913d6dfebee57ee8cee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
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 '通话已断开'
    }
  }
}