✨ feat(im):文本气泡 @ 高亮支持点击 + URL 自动识别成可点击链接
- @ 段:群消息按 atUserIds 反查群成员,候选 name 兼容历史字面量(真实昵称 / 好友备注 / 群自定义昵称),displayName 统一收敛到 nickname,让历史消息也能渲染成 @真实昵称;@ 段点击弹 UserInfoCard - @所有人:注入 IM_AT_ALL_USER_ID 虚拟候选,对齐微信 PC 仅高亮配色不挂点击 - 同名歧义:同字面量对应多个 userId 时标记 ambiguous,parser 整段消费成普通文本,避免错绑用户 - URL:识别 http(s) / www. 起头链接,<a target="_blank"> 新标签打开;默认补 https:// - TipSegment 加 link 变体作为统一文本片段类型,TEXT 气泡与灰条 tip 共用 TipSegments 组件渲染 - MessageInput @ token 文本统一用真实昵称,不再掺好友备注 / 群自定义昵称im
parent
094ab44094
commit
40ac2daca8
|
|
@ -739,12 +739,13 @@ function onMentionSelect(member: GroupMemberLite) {
|
||||||
}
|
}
|
||||||
// 删 @keyword,插入 contenteditable=false 的 token:
|
// 删 @keyword,插入 contenteditable=false 的 token:
|
||||||
// 删除时整段消除 + 不会被光标拆穿;data-id 是后续 collectFromEditor 收 atUserIds 的钩子
|
// 删除时整段消除 + 不会被光标拆穿;data-id 是后续 collectFromEditor 收 atUserIds 的钩子
|
||||||
|
// token 文本固定走真实昵称:群里所有成员看到的字面量一致,避免我侧的好友备注 / 群昵称污染发送文本
|
||||||
mentionRange.deleteContents()
|
mentionRange.deleteContents()
|
||||||
const span = document.createElement('span')
|
const span = document.createElement('span')
|
||||||
span.className = 'mention-token'
|
span.className = 'mention-token'
|
||||||
span.dataset.id = String(member.userId)
|
span.dataset.id = String(member.userId)
|
||||||
span.contentEditable = 'false'
|
span.contentEditable = 'false'
|
||||||
span.textContent = `@${member.showName}`
|
span.textContent = `@${member.nickname || member.showName}`
|
||||||
mentionRange.insertNode(span)
|
mentionRange.insertNode(span)
|
||||||
// token 在 editor 首位时,contenteditable=false 边缘会让光标无法挪到 token 前
|
// token 在 editor 首位时,contenteditable=false 边缘会让光标无法挪到 token 前
|
||||||
// 补一个零宽空格 当锚点;DOM walk 时会被滤掉,不进入发送内容
|
// 补一个零宽空格 当锚点;DOM walk 时会被滤掉,不进入发送内容
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- 文本 -->
|
<!-- 文本:按 segment 渲染,mention 高亮可点击、URL 自动识别成可点击链接 -->
|
||||||
<div
|
<div
|
||||||
v-if="isText && textPayload"
|
v-if="isText && textPayload"
|
||||||
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
|
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
|
||||||
:class="bubbleClass('text')"
|
:class="bubbleClass('text')"
|
||||||
>
|
>
|
||||||
{{ textPayload.content }}
|
<TipSegments :segments="textSegments" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片:el-image 内置预览;上传中半透明遮罩 -->
|
<!-- 图片:el-image 内置预览;上传中半透明遮罩 -->
|
||||||
|
|
@ -167,18 +167,21 @@ import { formatSeconds } from '@/utils/formatTime'
|
||||||
import { ImMessageType, MERGE_FORWARD_PREVIEW_LINES } from '@/views/im/utils/constants'
|
import { ImMessageType, MERGE_FORWARD_PREVIEW_LINES } from '@/views/im/utils/constants'
|
||||||
import {
|
import {
|
||||||
parseMessage,
|
parseMessage,
|
||||||
|
parseTextSegments,
|
||||||
getFileIconInfo,
|
getFileIconInfo,
|
||||||
type AudioMessage,
|
type AudioMessage,
|
||||||
type CardMessage,
|
type CardMessage,
|
||||||
type FaceMessage,
|
type FaceMessage,
|
||||||
type FileMessage,
|
type FileMessage,
|
||||||
type ImageMessage,
|
type ImageMessage,
|
||||||
|
type MentionCandidate,
|
||||||
type MergeMessage,
|
type MergeMessage,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type VideoMessage
|
type VideoMessage
|
||||||
} from '@/views/im/utils/message'
|
} from '@/views/im/utils/message'
|
||||||
import { summarizeMessageContent } from '@/views/im/utils/conversation'
|
import { summarizeMessageContent } from '@/views/im/utils/conversation'
|
||||||
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
|
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
|
||||||
|
import TipSegments from './TipSegments.vue'
|
||||||
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
|
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
|
||||||
|
|
||||||
defineOptions({ name: 'ImMessageBubble' })
|
defineOptions({ name: 'ImMessageBubble' })
|
||||||
|
|
@ -192,6 +195,8 @@ const props = defineProps<{
|
||||||
selfSend?: boolean
|
selfSend?: boolean
|
||||||
/** 媒体上传进度(0-100);非 null 即视为上传中,渲染遮罩 / 进度条 */
|
/** 媒体上传进度(0-100);非 null 即视为上传中,渲染遮罩 / 进度条 */
|
||||||
uploadProgress?: number | null
|
uploadProgress?: number | null
|
||||||
|
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
|
||||||
|
mentions?: MentionCandidate[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -224,6 +229,15 @@ const uploadProgressText = computed(() => `${uploadProgress.value}%`)
|
||||||
const parsedContent = computed<unknown>(() => parseMessage(props.content))
|
const parsedContent = computed<unknown>(() => parseMessage(props.content))
|
||||||
|
|
||||||
const textPayload = computed(() => (isText.value ? (parsedContent.value as TextMessage | null) : null))
|
const textPayload = computed(() => (isText.value ? (parsedContent.value as TextMessage | null) : null))
|
||||||
|
|
||||||
|
/** 文本气泡 segment 数组:mention 高亮 + URL 自动识别 + 普通文本三段拼接 */
|
||||||
|
const textSegments = computed(() => {
|
||||||
|
const content = textPayload.value?.content
|
||||||
|
if (!content) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return parseTextSegments(content, props.mentions || [])
|
||||||
|
})
|
||||||
const imagePayload = computed(() =>
|
const imagePayload = computed(() =>
|
||||||
isImage.value ? (parsedContent.value as ImageMessage | null) : null
|
isImage.value ? (parsedContent.value as ImageMessage | null) : null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
:content="message.content"
|
:content="message.content"
|
||||||
:self-send="message.selfSend"
|
:self-send="message.selfSend"
|
||||||
:upload-progress="message.uploadProgress"
|
:upload-progress="message.uploadProgress"
|
||||||
|
:mentions="textMentions"
|
||||||
@click-card="handleCardClick"
|
@click-card="handleCardClick"
|
||||||
@open-merge="handleMergeOpen"
|
@open-merge="handleMergeOpen"
|
||||||
/>
|
/>
|
||||||
|
|
@ -176,6 +177,7 @@ import {
|
||||||
getQuoteFromMessage,
|
getQuoteFromMessage,
|
||||||
parseMessage,
|
parseMessage,
|
||||||
type CardMessage,
|
type CardMessage,
|
||||||
|
type MentionCandidate,
|
||||||
type TextMessage
|
type TextMessage
|
||||||
} from '@/views/im/utils/message'
|
} from '@/views/im/utils/message'
|
||||||
import { buildRecallTipSegments } from '@/views/im/utils/conversation'
|
import { buildRecallTipSegments } from '@/views/im/utils/conversation'
|
||||||
|
|
@ -188,6 +190,7 @@ import { useDraftStore } from '../../../../store/draftStore'
|
||||||
import { useFaceStore } from '../../../../store/faceStore'
|
import { useFaceStore } from '../../../../store/faceStore'
|
||||||
import {
|
import {
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
|
getMentionCandidates,
|
||||||
getSenderDisplayName,
|
getSenderDisplayName,
|
||||||
getSenderRealNickname,
|
getSenderRealNickname,
|
||||||
resolveFriendNotificationSegments,
|
resolveFriendNotificationSegments,
|
||||||
|
|
@ -305,6 +308,14 @@ function handleMergeOpen(content: string) {
|
||||||
openMergeDetail?.(content)
|
openMergeDetail?.(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文本气泡 @ mention 候选名字:仅群消息有效,按 atUserIds 反查群成员真实昵称;非 TEXT 不走 store 读,让 getMentionCandidates 直接返回稳定空数组 */
|
||||||
|
const textMentions = computed<MentionCandidate[]>(() => {
|
||||||
|
if (props.message.type !== ImMessageType.TEXT) {
|
||||||
|
return getMentionCandidates(undefined, null)
|
||||||
|
}
|
||||||
|
return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation)
|
||||||
|
})
|
||||||
|
|
||||||
/** 名片点击:用户名片弹 UserInfoCard,群名片弹 GroupInfoCard;其它 targetType(含改包脏数据)忽略 */
|
/** 名片点击:用户名片弹 UserInfoCard,群名片弹 GroupInfoCard;其它 targetType(含改包脏数据)忽略 */
|
||||||
function handleCardClick(card: CardMessage, e: MouseEvent) {
|
function handleCardClick(card: CardMessage, e: MouseEvent) {
|
||||||
if (!card?.targetId) {
|
if (!card?.targetId) {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,35 @@
|
||||||
<!--
|
<!--
|
||||||
会话内灰条 tip 片段渲染:依赖 activeConversation 推断点击落点的 addSource / 群名
|
消息文本片段渲染:tip 文案 + TEXT 气泡共用
|
||||||
|
- mention 段挂点击 → openMentionUserInfoCardAtEvent
|
||||||
|
- link 段渲染 <a target="_blank">,浏览器默认行为打开新标签
|
||||||
|
- text 段原样输出
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<template v-for="(segment, _index) in segments" :key="_index">
|
<template v-for="(segment, _index) in segments" :key="_index">
|
||||||
<span
|
<span
|
||||||
v-if="segment.type === 'mention'"
|
v-if="segment.type === 'mention'"
|
||||||
class="cursor-pointer text-[#576b95] hover:underline"
|
class="text-[#576b95]"
|
||||||
|
:class="{ 'cursor-pointer hover:underline': isClickableMention(segment) }"
|
||||||
@click.stop="handleMentionClick(segment, $event)"
|
@click.stop="handleMentionClick(segment, $event)"
|
||||||
>{{ segment.text }}</span
|
>{{ segment.text }}</span
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
v-else-if="segment.type === 'link'"
|
||||||
|
:href="segment.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-[#576b95] hover:underline break-all"
|
||||||
|
@click.stop
|
||||||
|
>{{ segment.text }}</a
|
||||||
|
>
|
||||||
<span v-else>{{ segment.text }}</span>
|
<span v-else>{{ segment.text }}</span>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ImConversationType, ImFriendAddSource } from '@/views/im/utils/constants'
|
import { IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
|
||||||
import type { TipSegment } from '@/views/im/utils/message'
|
import type { TipSegment } from '@/views/im/utils/message'
|
||||||
import { getSenderAvatar } from '@/views/im/utils/user'
|
import { openMentionUserInfoCardAtEvent } from '@/views/im/utils/user'
|
||||||
import type { User } from '../../../../types'
|
|
||||||
import { useConversationStore } from '../../../../store/conversationStore'
|
|
||||||
import { useFriendStore } from '../../../../store/friendStore'
|
|
||||||
import { useGroupStore } from '../../../../store/groupStore'
|
|
||||||
import { useImUiStore } from '../../../../store/uiStore'
|
|
||||||
|
|
||||||
defineOptions({ name: 'ImTipSegments' })
|
defineOptions({ name: 'ImTipSegments' })
|
||||||
|
|
||||||
|
|
@ -29,34 +37,19 @@ defineProps<{
|
||||||
segments: TipSegment[]
|
segments: TipSegment[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
/** @全体成员是广播 mention,仅高亮配色,不挂可点击交互 */
|
||||||
* nickname 不传 friend.displayName / member.displayUserName 等备注,避免 UserInfo 首屏闪非真实昵称;
|
function isClickableMention(segment: { userId: number }): boolean {
|
||||||
* addSourceExtra 不用 conversation.name 兜底,conversation.name = groupRemark || group.name,
|
return segment.userId !== IM_AT_ALL_USER_ID
|
||||||
* 个人备注会污染加好友话术
|
}
|
||||||
*/
|
|
||||||
|
/** mention 段点击:fallbackName 取 segment 文本,避免 friend / member 都查不到时弹空 */
|
||||||
function handleMentionClick(
|
function handleMentionClick(
|
||||||
segment: { type: 'mention'; userId: number; text: string },
|
segment: { type: 'mention'; userId: number; text: string },
|
||||||
event: MouseEvent
|
event: MouseEvent
|
||||||
) {
|
) {
|
||||||
const conversation = useConversationStore().activeConversation
|
if (!isClickableMention(segment)) {
|
||||||
const isGroup = conversation?.type === ImConversationType.GROUP
|
return
|
||||||
const group = isGroup ? useGroupStore().getGroup(conversation!.targetId) : undefined
|
|
||||||
const member = group?.members?.find((m) => m.userId === segment.userId)
|
|
||||||
const friend = useFriendStore().getFriend(segment.userId)
|
|
||||||
const user: User = {
|
|
||||||
id: segment.userId,
|
|
||||||
nickname: friend?.nickname || member?.nickname || segment.text,
|
|
||||||
avatar: getSenderAvatar(
|
|
||||||
segment.userId,
|
|
||||||
conversation?.type ?? 0,
|
|
||||||
conversation?.targetId ?? 0
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
useImUiStore().openUserInfoCardAtEvent(
|
openMentionUserInfoCardAtEvent(segment.userId, event, segment.text)
|
||||||
user,
|
|
||||||
event,
|
|
||||||
isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
|
|
||||||
isGroup ? group?.name || '' : ''
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,15 @@ export const generateClientMessageId = (): string => {
|
||||||
return generateUUID()
|
return generateUUID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Tip 片段(灰条文案渲染用) ====================
|
// ==================== 文本片段(tip 文案 + TEXT 气泡共用) ====================
|
||||||
// 把"XX 邀请 YY 加入群聊""XX 撤回了一条消息"等 tip 文案拆成 segment 数组,
|
// 既用于灰条 tip("XX 邀请 YY 加入群聊"),也用于 TEXT 气泡正文(@xxx 高亮 + URL 自动识别)。
|
||||||
// mention 段携带 userId,渲染层据此挂点击事件弹 UserInfoCard。
|
// mention 段携带 userId 用于挂点击弹 UserInfoCard;link 段携带 href 用于 <a> 跳转;
|
||||||
|
// text 段是纯文本兜底。渲染层(TipSegments.vue)按 type 分发统一处理。
|
||||||
|
|
||||||
export type TipSegment =
|
export type TipSegment =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
| { type: 'mention'; userId: number; 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 tipText = (text: string): TipSegment => ({ type: 'text', text })
|
||||||
|
|
||||||
|
|
@ -38,6 +40,12 @@ export const tipMention = (userId: number, text: string): TipSegment => ({
|
||||||
text
|
text
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const tipLink = (href: string, text: string): TipSegment => ({
|
||||||
|
type: 'link',
|
||||||
|
href,
|
||||||
|
text
|
||||||
|
})
|
||||||
|
|
||||||
export const segmentsToText = (segments: TipSegment[]): string =>
|
export const segmentsToText = (segments: TipSegment[]): string =>
|
||||||
segments.map((s) => s.text).join('')
|
segments.map((s) => s.text).join('')
|
||||||
|
|
||||||
|
|
@ -54,6 +62,101 @@ export function joinMentionSegments(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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) */
|
/** 引用消息 payload(对齐后端 QuoteMessage) */
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,35 @@
|
||||||
// 命名约定:显示名相关函数一律使用 displayName,与 friend.displayName / member.displayUserName 字段对齐
|
// 命名约定:显示名相关函数一律使用 displayName,与 friend.displayName / member.displayUserName 字段对齐
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
|
||||||
|
import { countBy } from 'lodash-es'
|
||||||
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { SystemUserSexEnum } from '@/utils/constants'
|
import { SystemUserSexEnum } from '@/utils/constants'
|
||||||
import { ImConversationType, ImMessageType } from './constants'
|
import {
|
||||||
|
ImConversationType,
|
||||||
|
ImFriendAddSource,
|
||||||
|
ImMessageType,
|
||||||
|
IM_AT_ALL_NICKNAME,
|
||||||
|
IM_AT_ALL_USER_ID
|
||||||
|
} from './constants'
|
||||||
import { getCurrentUserId } from './storage'
|
import { getCurrentUserId } from './storage'
|
||||||
import {
|
import {
|
||||||
joinMentionSegments,
|
joinMentionSegments,
|
||||||
segmentsToText,
|
segmentsToText,
|
||||||
tipMention,
|
tipMention,
|
||||||
tipText,
|
tipText,
|
||||||
|
type MentionCandidate,
|
||||||
type TipSegment
|
type TipSegment
|
||||||
} from './message'
|
} from './message'
|
||||||
|
import { useConversationStore } from '../home/store/conversationStore'
|
||||||
import { useFriendStore } from '../home/store/friendStore'
|
import { useFriendStore } from '../home/store/friendStore'
|
||||||
import { useGroupStore } from '../home/store/groupStore'
|
import { useGroupStore } from '../home/store/groupStore'
|
||||||
import type { Friend, Group } from '../home/types'
|
import { useImUiStore } from '../home/store/uiStore'
|
||||||
|
import type { Conversation, Friend, Group, User } from '../home/types'
|
||||||
|
|
||||||
|
// 候选缺失场景的稳定空数组;让 textMentions computed 在非 TEXT / 非群聊 / 无 @ 时返回同一引用,
|
||||||
|
// MessageBubble 的 textSegments 才不会跟着无谓重算
|
||||||
|
const EMPTY_MENTIONS: MentionCandidate[] = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 私聊好友显示名:备注 > 真实昵称
|
* 私聊好友显示名:备注 > 真实昵称
|
||||||
|
|
@ -161,7 +176,6 @@ export function getSenderRealNickname(
|
||||||
return String(senderId)
|
return String(senderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:这个方法,还需要么?之前是哪个调用的,可能要看看。
|
|
||||||
/**
|
/**
|
||||||
* 消息发送者头像;按 conversation 上下文实时查 group.members / friend / userStore
|
* 消息发送者头像;按 conversation 上下文实时查 group.members / friend / userStore
|
||||||
*
|
*
|
||||||
|
|
@ -189,6 +203,104 @@ export function getSenderAvatar(
|
||||||
return useFriendStore().getFriend(senderId)?.avatar || ''
|
return useFriendStore().getFriend(senderId)?.avatar || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群消息 @ mention 候选:按 atUserIds 反查群成员,name 收集所有可能字面量(含好友备注 / 群自定义昵称),displayName 统一指向真实昵称
|
||||||
|
*
|
||||||
|
* 同字面量被多 userId 共享时标记 ambiguous,由 parser 整段吃成普通文本;
|
||||||
|
* 直接剔除会让短前缀候选(如「@张」)抢吃「@张三」的前缀,错绑到唯一的「张」用户
|
||||||
|
*/
|
||||||
|
export function getMentionCandidates(
|
||||||
|
atUserIds: number[] | undefined,
|
||||||
|
conversation: Pick<Conversation, 'type' | 'targetId'> | null | undefined
|
||||||
|
): MentionCandidate[] {
|
||||||
|
if (!atUserIds || atUserIds.length === 0) {
|
||||||
|
return EMPTY_MENTIONS
|
||||||
|
}
|
||||||
|
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||||
|
return EMPTY_MENTIONS
|
||||||
|
}
|
||||||
|
// 群成员预建 Map,避免每个 atUserId 走一次 array find(@全体成员场景下成员数 × atUserIds 是 N²)
|
||||||
|
const members = useGroupStore().getGroup(conversation.targetId)?.members || []
|
||||||
|
const memberById = new Map(members.map((m) => [m.userId, m]))
|
||||||
|
const friendStore = useFriendStore()
|
||||||
|
const candidates: MentionCandidate[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const userId of atUserIds) {
|
||||||
|
// @全体成员是虚拟伪成员,userId = -1 在 group.members 里查不到,注入字面量「所有人」候选
|
||||||
|
if (userId === IM_AT_ALL_USER_ID) {
|
||||||
|
const key = `${IM_AT_ALL_USER_ID}#${IM_AT_ALL_NICKNAME}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
candidates.push({
|
||||||
|
userId: IM_AT_ALL_USER_ID,
|
||||||
|
name: IM_AT_ALL_NICKNAME,
|
||||||
|
displayName: IM_AT_ALL_NICKNAME
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const member = memberById.get(userId)
|
||||||
|
const friend = friendStore.getFriend(userId)
|
||||||
|
const nickname = (member?.nickname || friend?.nickname || '').trim()
|
||||||
|
if (!nickname) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const literal of [nickname, friend?.displayName, member?.displayUserName]) {
|
||||||
|
const trimmed = (literal || '').trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const key = `${userId}#${trimmed}`
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
candidates.push({ userId, name: trimmed, displayName: nickname })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// seen 已保证 (userId, name) 唯一,所以 countBy 出来的同 name 计数 > 1 一定是跨 userId 歧义
|
||||||
|
const nameCount = countBy(candidates, 'name')
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (nameCount[candidate.name] > 1) {
|
||||||
|
candidate.ambiguous = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本 / tip 里 mention 段点击的统一入口:派生 User 弹 UserInfoCard
|
||||||
|
*
|
||||||
|
* 头像走 getSenderAvatar;昵称取真实昵称,friend / member 都查不到时用 fallbackName 兜底(如灰条 tip 段的字面量文本);
|
||||||
|
* addSource 群聊带 GROUP + 群名(用于「加为好友」的来源 + 备注弹文案),私聊降级 SEARCH;
|
||||||
|
* @全体成员 是广播 mention 没有具体用户实体,对齐微信 PC 不弹卡片
|
||||||
|
*/
|
||||||
|
export function openMentionUserInfoCardAtEvent(
|
||||||
|
userId: number,
|
||||||
|
event: MouseEvent,
|
||||||
|
fallbackName?: string
|
||||||
|
): void {
|
||||||
|
if (userId === IM_AT_ALL_USER_ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const conversation = useConversationStore().activeConversation
|
||||||
|
const isGroup = conversation?.type === ImConversationType.GROUP
|
||||||
|
const group = isGroup ? useGroupStore().getGroup(conversation!.targetId) : undefined
|
||||||
|
const member = group?.members?.find((m) => m.userId === userId)
|
||||||
|
const friend = useFriendStore().getFriend(userId)
|
||||||
|
const user: User = {
|
||||||
|
id: userId,
|
||||||
|
nickname: friend?.nickname || member?.nickname || fallbackName || String(userId),
|
||||||
|
avatar: getSenderAvatar(userId, conversation?.type ?? 0, conversation?.targetId ?? 0)
|
||||||
|
}
|
||||||
|
useImUiStore().openUserInfoCardAtEvent(
|
||||||
|
user,
|
||||||
|
event,
|
||||||
|
isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
|
||||||
|
isGroup ? group?.name || '' : ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 群广播事件(GROUP_* 系列)的中文文案
|
* 群广播事件(GROUP_* 系列)的中文文案
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue