✨ 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:
|
||||
// 删除时整段消除 + 不会被光标拆穿;data-id 是后续 collectFromEditor 收 atUserIds 的钩子
|
||||
// token 文本固定走真实昵称:群里所有成员看到的字面量一致,避免我侧的好友备注 / 群昵称污染发送文本
|
||||
mentionRange.deleteContents()
|
||||
const span = document.createElement('span')
|
||||
span.className = 'mention-token'
|
||||
span.dataset.id = String(member.userId)
|
||||
span.contentEditable = 'false'
|
||||
span.textContent = `@${member.showName}`
|
||||
span.textContent = `@${member.nickname || member.showName}`
|
||||
mentionRange.insertNode(span)
|
||||
// token 在 editor 首位时,contenteditable=false 边缘会让光标无法挪到 token 前
|
||||
// 补一个零宽空格 当锚点;DOM walk 时会被滤掉,不进入发送内容
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<!-- 文本 -->
|
||||
<!-- 文本:按 segment 渲染,mention 高亮可点击、URL 自动识别成可点击链接 -->
|
||||
<div
|
||||
v-if="isText && textPayload"
|
||||
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
|
||||
:class="bubbleClass('text')"
|
||||
>
|
||||
{{ textPayload.content }}
|
||||
<TipSegments :segments="textSegments" />
|
||||
</div>
|
||||
|
||||
<!-- 图片:el-image 内置预览;上传中半透明遮罩 -->
|
||||
|
|
@ -167,18 +167,21 @@ import { formatSeconds } from '@/utils/formatTime'
|
|||
import { ImMessageType, MERGE_FORWARD_PREVIEW_LINES } from '@/views/im/utils/constants'
|
||||
import {
|
||||
parseMessage,
|
||||
parseTextSegments,
|
||||
getFileIconInfo,
|
||||
type AudioMessage,
|
||||
type CardMessage,
|
||||
type FaceMessage,
|
||||
type FileMessage,
|
||||
type ImageMessage,
|
||||
type MentionCandidate,
|
||||
type MergeMessage,
|
||||
type TextMessage,
|
||||
type VideoMessage
|
||||
} from '@/views/im/utils/message'
|
||||
import { summarizeMessageContent } from '@/views/im/utils/conversation'
|
||||
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
|
||||
import TipSegments from './TipSegments.vue'
|
||||
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
|
||||
|
||||
defineOptions({ name: 'ImMessageBubble' })
|
||||
|
|
@ -192,6 +195,8 @@ const props = defineProps<{
|
|||
selfSend?: boolean
|
||||
/** 媒体上传进度(0-100);非 null 即视为上传中,渲染遮罩 / 进度条 */
|
||||
uploadProgress?: number | null
|
||||
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
|
||||
mentions?: MentionCandidate[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -224,6 +229,15 @@ const uploadProgressText = computed(() => `${uploadProgress.value}%`)
|
|||
const parsedContent = computed<unknown>(() => parseMessage(props.content))
|
||||
|
||||
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(() =>
|
||||
isImage.value ? (parsedContent.value as ImageMessage | null) : null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@
|
|||
:content="message.content"
|
||||
:self-send="message.selfSend"
|
||||
:upload-progress="message.uploadProgress"
|
||||
:mentions="textMentions"
|
||||
@click-card="handleCardClick"
|
||||
@open-merge="handleMergeOpen"
|
||||
/>
|
||||
|
|
@ -176,6 +177,7 @@ import {
|
|||
getQuoteFromMessage,
|
||||
parseMessage,
|
||||
type CardMessage,
|
||||
type MentionCandidate,
|
||||
type TextMessage
|
||||
} from '@/views/im/utils/message'
|
||||
import { buildRecallTipSegments } from '@/views/im/utils/conversation'
|
||||
|
|
@ -188,6 +190,7 @@ import { useDraftStore } from '../../../../store/draftStore'
|
|||
import { useFaceStore } from '../../../../store/faceStore'
|
||||
import {
|
||||
getMemberDisplayName,
|
||||
getMentionCandidates,
|
||||
getSenderDisplayName,
|
||||
getSenderRealNickname,
|
||||
resolveFriendNotificationSegments,
|
||||
|
|
@ -305,6 +308,14 @@ function handleMergeOpen(content: string) {
|
|||
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(含改包脏数据)忽略 */
|
||||
function handleCardClick(card: CardMessage, e: MouseEvent) {
|
||||
if (!card?.targetId) {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,35 @@
|
|||
<!--
|
||||
会话内灰条 tip 片段渲染:依赖 activeConversation 推断点击落点的 addSource / 群名
|
||||
消息文本片段渲染:tip 文案 + TEXT 气泡共用
|
||||
- mention 段挂点击 → openMentionUserInfoCardAtEvent
|
||||
- link 段渲染 <a target="_blank">,浏览器默认行为打开新标签
|
||||
- text 段原样输出
|
||||
-->
|
||||
<template>
|
||||
<template v-for="(segment, _index) in segments" :key="_index">
|
||||
<span
|
||||
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)"
|
||||
>{{ 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>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<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 { getSenderAvatar } 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'
|
||||
import { openMentionUserInfoCardAtEvent } from '@/views/im/utils/user'
|
||||
|
||||
defineOptions({ name: 'ImTipSegments' })
|
||||
|
||||
|
|
@ -29,34 +37,19 @@ defineProps<{
|
|||
segments: TipSegment[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* nickname 不传 friend.displayName / member.displayUserName 等备注,避免 UserInfo 首屏闪非真实昵称;
|
||||
* addSourceExtra 不用 conversation.name 兜底,conversation.name = groupRemark || group.name,
|
||||
* 个人备注会污染加好友话术
|
||||
*/
|
||||
/** @全体成员是广播 mention,仅高亮配色,不挂可点击交互 */
|
||||
function isClickableMention(segment: { userId: number }): boolean {
|
||||
return segment.userId !== IM_AT_ALL_USER_ID
|
||||
}
|
||||
|
||||
/** mention 段点击:fallbackName 取 segment 文本,避免 friend / member 都查不到时弹空 */
|
||||
function handleMentionClick(
|
||||
segment: { type: 'mention'; userId: number; text: string },
|
||||
event: MouseEvent
|
||||
) {
|
||||
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 === 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
|
||||
)
|
||||
if (!isClickableMention(segment)) {
|
||||
return
|
||||
}
|
||||
useImUiStore().openUserInfoCardAtEvent(
|
||||
user,
|
||||
event,
|
||||
isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
|
||||
isGroup ? group?.name || '' : ''
|
||||
)
|
||||
openMentionUserInfoCardAtEvent(segment.userId, event, segment.text)
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -22,13 +22,15 @@ export const generateClientMessageId = (): string => {
|
|||
return generateUUID()
|
||||
}
|
||||
|
||||
// ==================== Tip 片段(灰条文案渲染用) ====================
|
||||
// 把"XX 邀请 YY 加入群聊""XX 撤回了一条消息"等 tip 文案拆成 segment 数组,
|
||||
// mention 段携带 userId,渲染层据此挂点击事件弹 UserInfoCard。
|
||||
// ==================== 文本片段(tip 文案 + TEXT 气泡共用) ====================
|
||||
// 既用于灰条 tip("XX 邀请 YY 加入群聊"),也用于 TEXT 气泡正文(@xxx 高亮 + URL 自动识别)。
|
||||
// mention 段携带 userId 用于挂点击弹 UserInfoCard;link 段携带 href 用于 <a> 跳转;
|
||||
// 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 })
|
||||
|
||||
|
|
@ -38,6 +40,12 @@ export const tipMention = (userId: number, text: string): TipSegment => ({
|
|||
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('')
|
||||
|
||||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -9,20 +9,35 @@
|
|||
// 命名约定:显示名相关函数一律使用 displayName,与 friend.displayName / member.displayUserName 字段对齐
|
||||
// ====================================================================
|
||||
|
||||
import { countBy } from 'lodash-es'
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
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 {
|
||||
joinMentionSegments,
|
||||
segmentsToText,
|
||||
tipMention,
|
||||
tipText,
|
||||
type MentionCandidate,
|
||||
type TipSegment
|
||||
} from './message'
|
||||
import { useConversationStore } from '../home/store/conversationStore'
|
||||
import { useFriendStore } from '../home/store/friendStore'
|
||||
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)
|
||||
}
|
||||
|
||||
// TODO @AI:这个方法,还需要么?之前是哪个调用的,可能要看看。
|
||||
/**
|
||||
* 消息发送者头像;按 conversation 上下文实时查 group.members / friend / userStore
|
||||
*
|
||||
|
|
@ -189,6 +203,104 @@ export function getSenderAvatar(
|
|||
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_* 系列)的中文文案
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue