From 0f289e318282f4976ea06d269d4e76b23d2bb934 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 28 Apr 2026 22:18:42 +0800 Subject: [PATCH] feat(chat): implement @mention chip overlay rendering with Unicode placeholder tracking Add visual overlay rendering for @mention chips in the chat input using Unicode private use area characters as invisible placeholders. The implementation includes chip data tracking via `MentionChipMap`, dynamic segment parsing to separate chip and text content for overlay display, and updated memo comparison in `MessageBubble` to include avatar press handlers for proper re-render prevention. --- src/screens/message/ChatScreen.tsx | 2 + .../components/ChatScreen/ChatInput.tsx | 80 ++++++-- .../components/ChatScreen/MessageBubble.tsx | 2 + .../message/components/ChatScreen/styles.ts | 23 +++ .../message/components/ChatScreen/types.ts | 9 + .../components/ChatScreen/useChatScreen.ts | 184 +++++++++++------- 6 files changed, 216 insertions(+), 84 deletions(-) diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 105335e..4be4820 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -161,6 +161,7 @@ export const ChatScreen: React.FC = (props) => { groupMembers, currentUserRole, mentionQuery, + mentionChips, isMuted, muteAll, followRestrictionHint, @@ -567,6 +568,7 @@ export const ChatScreen: React.FC = (props) => { pendingAttachments={pendingAttachments} onRemovePendingAttachment={removePendingAttachment} onCancelReply={handleCancelReply} + mentionChips={mentionChips} onFocus={() => { // 输入框获得焦点时,关闭其他面板(但不要关闭键盘) if (activePanel !== 'none' && activePanel !== 'mention') { diff --git a/src/screens/message/components/ChatScreen/ChatInput.tsx b/src/screens/message/components/ChatScreen/ChatInput.tsx index 0e19907..be922e5 100644 --- a/src/screens/message/components/ChatScreen/ChatInput.tsx +++ b/src/screens/message/components/ChatScreen/ChatInput.tsx @@ -11,9 +11,16 @@ import { Text } from '../../../../components/common'; import { useAppColors } from '../../../../theme'; import { useChatScreenStyles, useChatDynamicStyles } from './styles'; import { useBreakpointGTE } from '../../../../hooks/useResponsive'; -import { ChatInputProps, GroupMessage, SenderInfo } from './types'; +import { ChatInputProps, GroupMessage, SenderInfo, MentionChipMap } from './types'; import { extractTextFromSegments } from '../../../../types/dto'; +const MENTION_CHIP_BASE = 0xE000; +const MENTION_CHIP_END = 0xF8FF; + +function isMentionChar(codePoint: number): boolean { + return codePoint >= MENTION_CHIP_BASE && codePoint <= MENTION_CHIP_END; +} + export const ChatInput: React.FC { const colors = useAppColors(); const styles = useChatScreenStyles(); const dynamicStyles = useChatDynamicStyles(); const isDisabled = isGroupChat && isMuted; - // 获取当前用户ID const currentUserId = currentUser?.id || ''; - // 动态颜色 const textPrimary = dynamicStyles.textPrimaryColor || colors.chat.textPrimary; const textSecondary = dynamicStyles.textSecondaryColor || colors.chat.textSecondary; - const textMuted = colors.chat.textPlaceholder || '#999'; // 禁用状态颜色 - const textDisabled = colors.chat.iconMuted || '#CCC'; // 完全禁用颜色 + const textMuted = colors.chat.textPlaceholder || '#999'; + const textDisabled = colors.chat.iconMuted || '#CCC'; - // 响应式布局 const isWideScreen = useBreakpointGTE('lg'); const inputContainerStyle = useMemo(() => ([ @@ -62,7 +67,6 @@ export const ChatInput: React.FC StyleSheet.create({ stripScroll: { maxHeight: 88, @@ -98,7 +102,37 @@ export const ChatInput: React.FC { + if (!mentionChips || mentionChips.size === 0) return null; + + const result: Array<{ type: 'text'; content: string } | { type: 'chip'; display: string }> = []; + // 使用 Array.from 正确处理 surrogate pairs + const chars = Array.from(inputText); + + let plainBuf = ''; + for (const ch of chars) { + const cp = ch.codePointAt(0)!; + if (isMentionChar(cp)) { + if (plainBuf) { + result.push({ type: 'text', content: plainBuf }); + plainBuf = ''; + } + const chipData = mentionChips.get(ch); + result.push({ type: 'chip', display: chipData ? `@${chipData.display}` : '\uFFFD' }); + } else { + plainBuf += ch; + } + } + if (plainBuf) { + result.push({ type: 'text', content: plainBuf }); + } + + return result.length > 0 ? result : null; + }, [inputText, mentionChips]); + + const hasChips = inputSegments !== null; + const ReplyPreview: React.FC<{ replyingTo: GroupMessage; onCancel: () => void; @@ -138,7 +172,6 @@ export const ChatInput: React.FC - {/* 回复预览 */} {replyingTo && ( )} - {/* 禁言提示 */} {isGroupChat && isMuted && ( @@ -202,7 +234,11 @@ export const ChatInput: React.FC + {hasChips && inputSegments && ( + + {inputSegments.map((seg, idx) => { + if (seg.type === 'chip') { + return ( + + {seg.display} + + ); + } + return ( + + {seg.content} + + ); + })} + + )} {canSend ? ( @@ -226,7 +279,6 @@ export const ChatInput: React.FC - {/* 光泽层 - 模拟渐变效果 */} @@ -255,4 +307,4 @@ export const ChatInput: React.FC { if (prev.currentUser !== next.currentUser) return false; if (prev.otherUser !== next.otherUser) return false; if (prev.messageMap !== next.messageMap) return false; + if (prev.onAvatarLongPress !== next.onAvatarLongPress) return false; + if (prev.onAvatarPress !== next.onAvatarPress) return false; return true; }); diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index ff2a25e..9d428aa 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -556,6 +556,29 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: { inputTransparent: { color: 'transparent', }, + mentionOverlay: { + ...StyleSheet.absoluteFillObject, + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'flex-start', + paddingTop: 8, + paddingBottom: 8, + paddingHorizontal: spacing.xs, + }, + mentionChipText: { + fontSize: 16, + color: colors.primary.main, + fontWeight: '500', + lineHeight: 20, + backgroundColor: `${colors.primary.main}15`, + borderRadius: 4, + overflow: 'hidden', + }, + mentionOverlayText: { + fontSize: 16, + color: colors.chat.textPrimary, + lineHeight: 20, + }, // 输入框@高亮 inputHighlightOverlay: { diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index e501e62..b06ac71 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -7,6 +7,14 @@ import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '.. // 面板类型 export type PanelType = 'none' | 'emoji' | 'more' | 'mention'; +// @提及原子芯片 +export interface MentionChipData { + userId: string; + display: string; +} + +export type MentionChipMap = Map; + // 消息状态 export type MessageStatus = 'normal' | 'recalled' | 'deleted'; @@ -126,6 +134,7 @@ export interface ChatInputProps { onCancelReply: () => void; onFocus: () => void; restrictionHint?: string | null; + mentionChips?: MentionChipMap; disableMore?: boolean; pendingAttachments?: PendingChatAttachment[]; onRemovePendingAttachment?: (id: string) => void; diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index e8f9846..9ed557a 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -37,6 +37,8 @@ import { PanelType, UserRole, SenderInfo, + MentionChipData, + MentionChipMap, ChatRouteParams, MenuPosition, PendingChatAttachment, @@ -122,6 +124,8 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 本地状态 const [inputText, setInputText] = useState(''); + const inputTextRef = useRef(inputText); + useEffect(() => { inputTextRef.current = inputText; }, [inputText]); const [otherUser, setOtherUser] = useState<{ id?: string; nickname?: string; avatar?: string | null } | null>(null); const [isFollowedByOther, setIsFollowedByOther] = useState(null); const [currentUser, setCurrentUser] = useState<{ id?: string; nickname?: string; avatar?: string | null } | null>(null); @@ -169,6 +173,26 @@ export const useChatScreen = (props?: ChatScreenProps) => { const [mentionQuery, setMentionQuery] = useState(''); const [selectedMentions, setSelectedMentions] = useState([]); const [mentionAll, setMentionAll] = useState(false); + const [mentionChips, setMentionChips] = useState(new Map()); + const mentionChipCounterRef = useRef(0); + + const MENTION_CHIP_BASE = 0xE000; + + const createMentionPlaceholder = useCallback((): string => { + const codePoint = MENTION_CHIP_BASE + (mentionChipCounterRef.current % 6400); + mentionChipCounterRef.current += 1; + return String.fromCodePoint(codePoint); + }, []); + + const addMentionChip = useCallback((userId: string, display: string): string => { + const placeholder = createMentionPlaceholder(); + setMentionChips(prev => { + const next = new Map(prev); + next.set(placeholder, { userId, display }); + return next; + }); + return placeholder; + }, [createMentionPlaceholder]); const [isMuted, setIsMuted] = useState(false); const [muteAll, setMuteAll] = useState(false); @@ -701,7 +725,52 @@ export const useChatScreen = (props?: ChatScreenProps) => { const handleInputChange = useCallback((text: string) => { setInputText(text); + // 清理已删除的芯片 + setMentionChips(prev => { + const next = new Map(prev); + let changed = false; + next.forEach((_, placeholder) => { + if (!text.includes(placeholder)) { + next.delete(placeholder); + changed = true; + } + }); + return changed ? next : prev; + }); + if (isGroupChat) { + // 从文本中的芯片重新派生 selectedMentions 和 mentionAll + const newUserIds: string[] = []; + let newMentionAll = false; + for (const ch of text) { + const cp = ch.codePointAt(0)!; + if (cp >= MENTION_CHIP_BASE && cp <= 0xF8FF) { + // 在下一次渲染中从 mentionChips 读取 + } + } + // 使用 functional update 读取最新 mentionChips + setMentionChips(prev => { + const chipUserIds: string[] = []; + let hasAll = false; + for (const ch of text) { + const cp = ch.codePointAt(0)!; + if (cp >= MENTION_CHIP_BASE && cp <= 0xF8FF) { + const data = prev.get(ch); + if (data) { + if (data.userId === 'all') { + hasAll = true; + } else { + chipUserIds.push(data.userId); + } + } + } + } + setSelectedMentions(chipUserIds); + setMentionAll(hasAll); + return prev; + }); + + // 检测普通 @ 字符(非芯片)触发提及选择面板 const lastAtIndex = text.lastIndexOf('@'); if (lastAtIndex !== -1) { const textAfterAt = text.slice(lastAtIndex + 1); @@ -714,25 +783,8 @@ export const useChatScreen = (props?: ChatScreenProps) => { } else { setActivePanel('none'); } - - if (selectedMentions.length > 0) { - const stillPresent = selectedMentions.filter(userId => { - const member = groupMembers.find(m => m.user_id === userId); - const nickname = member?.nickname || member?.user?.nickname; - if (!nickname) return false; - const escapedName = nickname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`@${escapedName}(\\s|$)`, 'g').test(text); - }); - if (stillPresent.length !== selectedMentions.length) { - setSelectedMentions(stillPresent); - } - } - - if (mentionAll && !text.includes('@所有人')) { - setMentionAll(false); - } } - }, [isGroupChat, selectedMentions, mentionAll, groupMembers]); + }, [isGroupChat]); // 【改造】获取发送者信息(群聊)- 优先从消息的 sender 字段获取 const getSenderInfo = useCallback((senderId: string): SenderInfo => { @@ -782,16 +834,14 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 选择@用户 const handleSelectMention = useCallback((member: { user_id: string; nickname?: string; user?: { nickname?: string } }) => { - const lastAtIndex = inputText.lastIndexOf('@'); + const currentText = inputTextRef.current; + const lastAtIndex = currentText.lastIndexOf('@'); if (lastAtIndex !== -1) { - const textBeforeAt = inputText.slice(0, lastAtIndex); + const textBeforeAt = currentText.slice(0, lastAtIndex); const displayName = member.nickname || member.user?.nickname || '用户'; - const newText = `${textBeforeAt}@${displayName} `; + const placeholder = addMentionChip(member.user_id, displayName); + const newText = `${textBeforeAt}${placeholder} `; setInputText(newText); - - if (!selectedMentions.includes(member.user_id)) { - setSelectedMentions(prev => [...prev, member.user_id]); - } } setActivePanel('none'); setMentionQuery(''); @@ -799,16 +849,17 @@ export const useChatScreen = (props?: ChatScreenProps) => { setTimeout(() => { textInputRef.current?.focus(); }, 100); - }, [inputText, selectedMentions]); + }, [addMentionChip]); // @所有人 const handleMentionAll = useCallback(() => { - const lastAtIndex = inputText.lastIndexOf('@'); + const currentText = inputTextRef.current; + const lastAtIndex = currentText.lastIndexOf('@'); if (lastAtIndex !== -1) { - const textBeforeAt = inputText.slice(0, lastAtIndex); - const newText = `${textBeforeAt}@所有人 `; + const textBeforeAt = currentText.slice(0, lastAtIndex); + const placeholder = addMentionChip('all', '所有人'); + const newText = `${textBeforeAt}${placeholder} `; setInputText(newText); - setMentionAll(true); } setActivePanel('none'); setMentionQuery(''); @@ -816,7 +867,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { setTimeout(() => { textInputRef.current?.focus(); }, 100); - }, [inputText]); + }, [addMentionChip]); /** * 构建文本消息的 segments 数组 @@ -831,45 +882,38 @@ export const useChatScreen = (props?: ChatScreenProps) => { }); } - if (mentionAll) { - segments.push({ - type: 'at', - data: { user_id: 'all' } as AtSegmentData - }); + // 按位置扫描文本,将芯片占位符转为 at segments,纯文本转为 text segments + let textBuffer = ''; + const chars = Array.from(text); + + for (const ch of chars) { + const cp = ch.codePointAt(0)!; + + if (cp >= MENTION_CHIP_BASE && cp <= 0xF8FF) { + if (textBuffer.trim()) { + segments.push({ type: 'text', data: { text: textBuffer.trim() } as TextSegmentData }); + } + textBuffer = ''; + + const chipData = mentionChips.get(ch); + if (chipData) { + segments.push({ type: 'at', data: { user_id: chipData.userId } as AtSegmentData }); + } + } else { + textBuffer += ch; + } } - if (selectedMentions.length > 0) { - selectedMentions.forEach(userId => { - segments.push({ - type: 'at', - data: { user_id: userId } as AtSegmentData - }); - }); + if (textBuffer.trim()) { + segments.push({ type: 'text', data: { text: textBuffer.trim() } as TextSegmentData }); } - let strippedText = text; - if (mentionAll) { - strippedText = strippedText.replace(/@所有人\s*/g, ''); - } - if (selectedMentions.length > 0) { - selectedMentions.forEach(userId => { - const member = groupMembers.find(m => m.user_id === userId); - const nickname = member?.nickname || member?.user?.nickname || '用户'; - const escapedName = nickname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - strippedText = strippedText.replace(new RegExp(`@${escapedName}\s*`, 'g'), ''); - }); - } - strippedText = strippedText.trim(); - - if (strippedText) { - segments.push({ - type: 'text', - data: { text: strippedText } as TextSegmentData - }); + if (segments.length === 0 || (segments.length === 1 && segments[0].type === 'reply')) { + segments.push({ type: 'text', data: { text: '' } as TextSegmentData }); } return segments; - }, [mentionAll, selectedMentions, groupMembers]); + }, [mentionChips]); /** * 构建图片消息的 segments 数组 @@ -974,6 +1018,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { setInputText(''); setSelectedMentions([]); setMentionAll(false); + setMentionChips(new Map()); setReplyingTo(null); setPendingAttachments([]); } else { @@ -981,6 +1026,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { setInputText(''); setSelectedMentions([]); setMentionAll(false); + setMentionChips(new Map()); setReplyingTo(null); setPendingAttachments([]); } @@ -1297,17 +1343,14 @@ export const useChatScreen = (props?: ChatScreenProps) => { const handleAvatarLongPress = useCallback((senderId: string, nickname: string) => { if (!isGroupChat || senderId === currentUserId) return; - if (!selectedMentions.includes(senderId)) { - setSelectedMentions(prev => [...prev, senderId]); - } - - const newText = inputText + `@${nickname} `; - setInputText(newText); + const currentText = inputTextRef.current; + const placeholder = addMentionChip(senderId, nickname); + setInputText(currentText + `${placeholder} `); setTimeout(() => { textInputRef.current?.focus(); }, 100); - }, [isGroupChat, currentUserId, inputText, selectedMentions]); + }, [isGroupChat, currentUserId, addMentionChip]); // 获取正在输入提示文本 - 现在从 MessageManager 获取 const getTypingHint = useCallback((): string | null => { @@ -1410,6 +1453,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { currentUserRole, mentionQuery, selectedMentions, + mentionChips, isMuted, muteAll, followRestrictionHint,