diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 4be4820..105335e 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -161,7 +161,6 @@ export const ChatScreen: React.FC = (props) => { groupMembers, currentUserRole, mentionQuery, - mentionChips, isMuted, muteAll, followRestrictionHint, @@ -568,7 +567,6 @@ 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 be922e5..55ce9ea 100644 --- a/src/screens/message/components/ChatScreen/ChatInput.tsx +++ b/src/screens/message/components/ChatScreen/ChatInput.tsx @@ -11,16 +11,9 @@ import { Text } from '../../../../components/common'; import { useAppColors } from '../../../../theme'; import { useChatScreenStyles, useChatDynamicStyles } from './styles'; import { useBreakpointGTE } from '../../../../hooks/useResponsive'; -import { ChatInputProps, GroupMessage, SenderInfo, MentionChipMap } from './types'; +import { ChatInputProps, GroupMessage, SenderInfo } 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(); @@ -102,37 +94,6 @@ 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; @@ -178,7 +139,7 @@ export const ChatInput: React.FC )} - + {isGroupChat && isMuted && ( @@ -218,27 +179,23 @@ export const ChatInput: React.FC )} - + - - + - + - {hasChips && inputSegments && ( - - {inputSegments.map((seg, idx) => { - if (seg.type === 'chip') { - return ( - - {seg.display} - - ); - } - return ( - - {seg.content} - - ); - })} - - )} - + {canSend ? ( ) : ( - - + )} diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 9d428aa..62d7655 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -553,33 +553,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: { inputMuted: { color: colors.chat.iconMuted, }, - 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: { ...StyleSheet.absoluteFillObject, diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index b06ac71..1c38f15 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -7,14 +7,6 @@ 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'; @@ -125,7 +117,6 @@ export interface ChatInputProps { onToggleEmoji: () => void; onToggleMore: () => void; activePanel: PanelType; - /** 发送消息或上传附件进行中,用于禁用发送按钮 */ isComposerBusy: boolean; isMuted: boolean; isGroupChat: boolean; @@ -134,7 +125,6 @@ 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 9ed557a..5f683bb 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -37,8 +37,6 @@ import { PanelType, UserRole, SenderInfo, - MentionChipData, - MentionChipMap, ChatRouteParams, MenuPosition, PendingChatAttachment, @@ -47,6 +45,44 @@ import { import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants'; import { messageRepository } from '@/database'; +interface MentionRange { + start: number; + end: number; + userId: string; +} + +function getMentionRanges( + text: string, + selectedMentions: string[], + mentionAll: boolean, + groupMembers: GroupMemberResponse[] +): MentionRange[] { + const ranges: MentionRange[] = []; + + if (mentionAll) { + const pattern = /@所有人\s*/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + ranges.push({ start: match.index, end: match.index + match[0].length, userId: 'all' }); + } + } + + for (const userId of selectedMentions) { + const member = groupMembers.find(m => m.user_id === userId); + const nickname = member?.nickname || member?.user?.nickname; + if (!nickname) continue; + const escapedName = nickname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`@${escapedName}\\s*`, 'g'); + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + ranges.push({ start: match.index, end: match.index + match[0].length, userId }); + } + } + + ranges.sort((a, b) => a.start - b.start); + return ranges; +} + export const useChatScreen = (props?: ChatScreenProps) => { const getSendErrorMessage = useCallback((error: unknown, fallback: string) => { if (error instanceof ApiError && error.message) { @@ -173,26 +209,7 @@ 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 prevInputTextRef = useRef(''); const [isMuted, setIsMuted] = useState(false); const [muteAll, setMuteAll] = useState(false); @@ -721,56 +738,64 @@ export const useChatScreen = (props?: ChatScreenProps) => { return (currentDate.getTime() - prevDate.getTime()) > TIME_SEPARATOR_INTERVAL; }, [messages]); - // 处理输入变化,检测@符号 + // 处理输入变化,检测@符号及 @mention 整体删除 const handleInputChange = useCallback((text: string) => { - setInputText(text); + const prevText = prevInputTextRef.current; - // 清理已删除的芯片 - 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 读取 + // 检测退格导致 @mention 被部分删除 → 整体补删(setNativeProps 立即生效无卡顿) + if (text.length < prevText.length && isGroupChat) { + let deleteStart = -1; + for (let i = 0; i < prevText.length; i++) { + if (prevText[i] !== text[i]) { + deleteStart = i; + break; } } - // 使用 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; - }); + if (deleteStart === -1) { + deleteStart = text.length; + } + const deleteEnd = deleteStart + (prevText.length - text.length); - // 检测普通 @ 字符(非芯片)触发提及选择面板 + const mentionRanges = getMentionRanges(prevText, selectedMentions, mentionAll, groupMembers); + + for (const range of mentionRanges) { + if (deleteStart < range.end && deleteEnd > range.start) { + const correctedText = prevText.slice(0, range.start) + prevText.slice(range.end); + prevInputTextRef.current = correctedText; + // 立即同步到原生层,避免二次渲染卡顿 + textInputRef.current?.setNativeProps({ text: correctedText }); + setInputText(correctedText); + + setSelectedMentions(prev => prev.filter(userId => { + const member = groupMembers.find(m => m.user_id === userId); + const nickname = member?.nickname || member?.user?.nickname; + return nickname && correctedText.includes(`@${nickname}`); + })); + if (mentionAll && !correctedText.includes('@所有人')) { + setMentionAll(false); + } + + const lastAtIndex = correctedText.lastIndexOf('@'); + if (lastAtIndex !== -1) { + const textAfterAt = correctedText.slice(lastAtIndex + 1); + if (!textAfterAt.includes(' ')) { + setMentionQuery(textAfterAt.toLowerCase()); + setActivePanel('mention'); + } else { + setActivePanel('none'); + } + } else { + setActivePanel('none'); + } + return; + } + } + } + + prevInputTextRef.current = text; + setInputText(text); + + if (isGroupChat) { const lastAtIndex = text.lastIndexOf('@'); if (lastAtIndex !== -1) { const textAfterAt = text.slice(lastAtIndex + 1); @@ -783,8 +808,25 @@ 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]); + }, [isGroupChat, selectedMentions, mentionAll, groupMembers]); // 【改造】获取发送者信息(群聊)- 优先从消息的 sender 字段获取 const getSenderInfo = useCallback((senderId: string): SenderInfo => { @@ -839,9 +881,12 @@ export const useChatScreen = (props?: ChatScreenProps) => { if (lastAtIndex !== -1) { const textBeforeAt = currentText.slice(0, lastAtIndex); const displayName = member.nickname || member.user?.nickname || '用户'; - const placeholder = addMentionChip(member.user_id, displayName); - const newText = `${textBeforeAt}${placeholder} `; + const newText = `${textBeforeAt}@${displayName} `; setInputText(newText); + + if (!selectedMentions.includes(member.user_id)) { + setSelectedMentions(prev => [...prev, member.user_id]); + } } setActivePanel('none'); setMentionQuery(''); @@ -849,7 +894,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { setTimeout(() => { textInputRef.current?.focus(); }, 100); - }, [addMentionChip]); + }, [selectedMentions]); // @所有人 const handleMentionAll = useCallback(() => { @@ -857,9 +902,9 @@ export const useChatScreen = (props?: ChatScreenProps) => { const lastAtIndex = currentText.lastIndexOf('@'); if (lastAtIndex !== -1) { const textBeforeAt = currentText.slice(0, lastAtIndex); - const placeholder = addMentionChip('all', '所有人'); - const newText = `${textBeforeAt}${placeholder} `; + const newText = `${textBeforeAt}@所有人 `; setInputText(newText); + setMentionAll(true); } setActivePanel('none'); setMentionQuery(''); @@ -867,7 +912,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { setTimeout(() => { textInputRef.current?.focus(); }, 100); - }, [addMentionChip]); + }, []); /** * 构建文本消息的 segments 数组 @@ -882,30 +927,57 @@ export const useChatScreen = (props?: ChatScreenProps) => { }); } - // 按位置扫描文本,将芯片占位符转为 at segments,纯文本转为 text segments - let textBuffer = ''; - const chars = Array.from(text); + // 收集所有 @ 提及在文本中的位置 + const mentionPositions: { start: number; end: number; userId: string }[] = []; - 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 (mentionAll) { + const pattern = /@所有人\s*/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + mentionPositions.push({ start: match.index, end: match.index + match[0].length, userId: 'all' }); } } - if (textBuffer.trim()) { - segments.push({ type: 'text', data: { text: textBuffer.trim() } as TextSegmentData }); + 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, '\\$&'); + const pattern = new RegExp(`@${escapedName}\\s*`, 'g'); + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + mentionPositions.push({ start: match.index, end: match.index + match[0].length, userId }); + } + }); + } + + mentionPositions.sort((a, b) => a.start - b.start); + + const seen = new Set(); + const uniquePositions = mentionPositions.filter(p => { + if (seen.has(p.start)) return false; + seen.add(p.start); + return true; + }); + + let cursor = 0; + for (const pos of uniquePositions) { + if (pos.start >= pos.end) continue; + if (pos.start > cursor) { + const plainText = text.slice(cursor, pos.start); + if (plainText) { + segments.push({ type: 'text', data: { text: plainText } as TextSegmentData }); + } + } + segments.push({ type: 'at', data: { user_id: pos.userId } as AtSegmentData }); + cursor = Math.max(cursor, pos.end); + } + + if (cursor < text.length) { + const remaining = text.slice(cursor); + if (remaining) { + segments.push({ type: 'text', data: { text: remaining } as TextSegmentData }); + } } if (segments.length === 0 || (segments.length === 1 && segments[0].type === 'reply')) { @@ -913,7 +985,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { } return segments; - }, [mentionChips]); + }, [mentionAll, selectedMentions, groupMembers]); /** * 构建图片消息的 segments 数组 @@ -1018,7 +1090,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { setInputText(''); setSelectedMentions([]); setMentionAll(false); - setMentionChips(new Map()); setReplyingTo(null); setPendingAttachments([]); } else { @@ -1026,7 +1097,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { setInputText(''); setSelectedMentions([]); setMentionAll(false); - setMentionChips(new Map()); setReplyingTo(null); setPendingAttachments([]); } @@ -1343,14 +1413,17 @@ 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 currentText = inputTextRef.current; - const placeholder = addMentionChip(senderId, nickname); - setInputText(currentText + `${placeholder} `); + setInputText(currentText + `@${nickname} `); setTimeout(() => { textInputRef.current?.focus(); }, 100); - }, [isGroupChat, currentUserId, addMentionChip]); + }, [isGroupChat, currentUserId, selectedMentions]); // 获取正在输入提示文本 - 现在从 MessageManager 获取 const getTypingHint = useCallback((): string | null => { @@ -1453,7 +1526,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { currentUserRole, mentionQuery, selectedMentions, - mentionChips, isMuted, muteAll, followRestrictionHint,