feat(chat): implement @mention chip overlay rendering with Unicode placeholder tracking
Some checks failed
Frontend CI / ota-android (push) Successful in 1m12s
Frontend CI / build-and-push-web (push) Successful in 3m28s
Frontend CI / build-android-apk (push) Failing after 1h23m13s

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.
This commit is contained in:
lafay
2026-04-28 22:18:42 +08:00
parent d2120a257d
commit 0f289e3182
6 changed files with 216 additions and 84 deletions

View File

@@ -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<boolean | null>(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<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 [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,