refactor(chat): remove Unicode placeholder-based mention chip overlay system
Replaced the previous approach of tracking @mentions using private Unicode characters (U+E000-U+F8FF) with a simpler regex-based solution that extracts mention ranges directly from input text. This eliminates the complex overlay rendering logic and corresponding styles/types.
This commit is contained in:
@@ -161,7 +161,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
groupMembers,
|
||||
currentUserRole,
|
||||
mentionQuery,
|
||||
mentionChips,
|
||||
isMuted,
|
||||
muteAll,
|
||||
followRestrictionHint,
|
||||
@@ -568,7 +567,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
pendingAttachments={pendingAttachments}
|
||||
onRemovePendingAttachment={removePendingAttachment}
|
||||
onCancelReply={handleCancelReply}
|
||||
mentionChips={mentionChips}
|
||||
onFocus={() => {
|
||||
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
|
||||
if (activePanel !== 'none' && activePanel !== 'mention') {
|
||||
|
||||
@@ -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<ChatInputProps & {
|
||||
currentUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||
@@ -46,7 +39,6 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
currentUser,
|
||||
otherUser,
|
||||
getSenderInfo,
|
||||
mentionChips,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useChatScreenStyles();
|
||||
@@ -102,37 +94,6 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
!isComposerBusy &&
|
||||
!isDisabled;
|
||||
|
||||
// 将输入文本拆分为原子 @芯片段和纯文本段,用于叠加层渲染
|
||||
const inputSegments = useMemo(() => {
|
||||
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<ChatInputProps & {
|
||||
onCancel={onCancelReply}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{isGroupChat && isMuted && (
|
||||
<View style={styles.mutedBanner}>
|
||||
<MaterialCommunityIcons name="minus-circle" size={16} color="#FF3B30" />
|
||||
@@ -218,27 +179,23 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
|
||||
<View style={[styles.inputInner, isDisabled && styles.inputInnerMuted]}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, activePanel === 'emoji' && styles.iconButtonActive]}
|
||||
onPress={onToggleEmoji}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'emoji' ? "keyboard" : "emoticon-happy-outline"}
|
||||
size={26}
|
||||
color={isDisabled ? textDisabled : (activePanel === 'emoji' ? colors.primary.main : textSecondary)}
|
||||
/>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'emoji' ? "keyboard" : "emoticon-happy-outline"}
|
||||
size={26}
|
||||
color={isDisabled ? textDisabled : (activePanel === 'emoji' ? colors.primary.main : textSecondary)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
<View style={styles.inputBox}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isDisabled && styles.inputMuted,
|
||||
hasChips && styles.inputTransparent,
|
||||
]}
|
||||
style={[styles.input, isDisabled && styles.inputMuted]}
|
||||
placeholder={isGroupChat ? (isMuted ? (muteAll ? "全员禁言中..." : "你已被禁言...") : "发送消息,@提及成员...") : "发送消息..."}
|
||||
placeholderTextColor={isDisabled ? textDisabled : textMuted}
|
||||
value={inputText}
|
||||
@@ -253,26 +210,8 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{hasChips && inputSegments && (
|
||||
<View style={styles.mentionOverlay} pointerEvents="none">
|
||||
{inputSegments.map((seg, idx) => {
|
||||
if (seg.type === 'chip') {
|
||||
return (
|
||||
<Text key={`c${idx}`} style={styles.mentionChipText}>
|
||||
{seg.display}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text key={`t${idx}`} style={styles.mentionOverlayText}>
|
||||
{seg.content}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
{canSend ? (
|
||||
<TouchableOpacity
|
||||
style={styles.sendButtonActive}
|
||||
@@ -290,16 +229,16 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, activePanel === 'more' && styles.iconButtonActive]}
|
||||
onPress={onToggleMore}
|
||||
disabled={isDisabled || disableMore}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'more' ? "close-circle" : "plus-circle-outline"}
|
||||
size={26}
|
||||
color={isDisabled || disableMore ? textDisabled : (activePanel === 'more' ? colors.primary.main : textSecondary)}
|
||||
/>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'more' ? "close-circle" : "plus-circle-outline"}
|
||||
size={26}
|
||||
color={isDisabled || disableMore ? textDisabled : (activePanel === 'more' ? colors.primary.main : textSecondary)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, MentionChipData>;
|
||||
|
||||
// 消息状态
|
||||
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;
|
||||
|
||||
@@ -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<string>('');
|
||||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||||
const [mentionAll, setMentionAll] = useState(false);
|
||||
const [mentionChips, setMentionChips] = useState<MentionChipMap>(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<number>();
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user