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.
This commit is contained in:
@@ -161,6 +161,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
groupMembers,
|
groupMembers,
|
||||||
currentUserRole,
|
currentUserRole,
|
||||||
mentionQuery,
|
mentionQuery,
|
||||||
|
mentionChips,
|
||||||
isMuted,
|
isMuted,
|
||||||
muteAll,
|
muteAll,
|
||||||
followRestrictionHint,
|
followRestrictionHint,
|
||||||
@@ -567,6 +568,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
pendingAttachments={pendingAttachments}
|
pendingAttachments={pendingAttachments}
|
||||||
onRemovePendingAttachment={removePendingAttachment}
|
onRemovePendingAttachment={removePendingAttachment}
|
||||||
onCancelReply={handleCancelReply}
|
onCancelReply={handleCancelReply}
|
||||||
|
mentionChips={mentionChips}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
|
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
|
||||||
if (activePanel !== 'none' && activePanel !== 'mention') {
|
if (activePanel !== 'none' && activePanel !== 'mention') {
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ import { Text } from '../../../../components/common';
|
|||||||
import { useAppColors } from '../../../../theme';
|
import { useAppColors } from '../../../../theme';
|
||||||
import { useChatScreenStyles, useChatDynamicStyles } from './styles';
|
import { useChatScreenStyles, useChatDynamicStyles } from './styles';
|
||||||
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||||
import { ChatInputProps, GroupMessage, SenderInfo } from './types';
|
import { ChatInputProps, GroupMessage, SenderInfo, MentionChipMap } from './types';
|
||||||
import { extractTextFromSegments } from '../../../../types/dto';
|
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 & {
|
export const ChatInput: React.FC<ChatInputProps & {
|
||||||
currentUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
currentUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||||
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||||
@@ -39,22 +46,20 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
currentUser,
|
currentUser,
|
||||||
otherUser,
|
otherUser,
|
||||||
getSenderInfo,
|
getSenderInfo,
|
||||||
|
mentionChips,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useChatScreenStyles();
|
const styles = useChatScreenStyles();
|
||||||
const dynamicStyles = useChatDynamicStyles();
|
const dynamicStyles = useChatDynamicStyles();
|
||||||
const isDisabled = isGroupChat && isMuted;
|
const isDisabled = isGroupChat && isMuted;
|
||||||
|
|
||||||
// 获取当前用户ID
|
|
||||||
const currentUserId = currentUser?.id || '';
|
const currentUserId = currentUser?.id || '';
|
||||||
|
|
||||||
// 动态颜色
|
|
||||||
const textPrimary = dynamicStyles.textPrimaryColor || colors.chat.textPrimary;
|
const textPrimary = dynamicStyles.textPrimaryColor || colors.chat.textPrimary;
|
||||||
const textSecondary = dynamicStyles.textSecondaryColor || colors.chat.textSecondary;
|
const textSecondary = dynamicStyles.textSecondaryColor || colors.chat.textSecondary;
|
||||||
const textMuted = colors.chat.textPlaceholder || '#999'; // 禁用状态颜色
|
const textMuted = colors.chat.textPlaceholder || '#999';
|
||||||
const textDisabled = colors.chat.iconMuted || '#CCC'; // 完全禁用颜色
|
const textDisabled = colors.chat.iconMuted || '#CCC';
|
||||||
|
|
||||||
// 响应式布局
|
|
||||||
const isWideScreen = useBreakpointGTE('lg');
|
const isWideScreen = useBreakpointGTE('lg');
|
||||||
|
|
||||||
const inputContainerStyle = useMemo(() => ([
|
const inputContainerStyle = useMemo(() => ([
|
||||||
@@ -62,7 +67,6 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||||
]), [isWideScreen, styles.inputContainer]);
|
]), [isWideScreen, styles.inputContainer]);
|
||||||
|
|
||||||
// 附件样式 - 动态生成以使用主题色
|
|
||||||
const attachmentStyles = useMemo(() => StyleSheet.create({
|
const attachmentStyles = useMemo(() => StyleSheet.create({
|
||||||
stripScroll: {
|
stripScroll: {
|
||||||
maxHeight: 88,
|
maxHeight: 88,
|
||||||
@@ -98,7 +102,37 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
!isComposerBusy &&
|
!isComposerBusy &&
|
||||||
!isDisabled;
|
!isDisabled;
|
||||||
|
|
||||||
// 回复预览组件 - 定义在内部以访问 styles
|
// 将输入文本拆分为原子 @芯片段和纯文本段,用于叠加层渲染
|
||||||
|
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<{
|
const ReplyPreview: React.FC<{
|
||||||
replyingTo: GroupMessage;
|
replyingTo: GroupMessage;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -138,7 +172,6 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={inputContainerStyle}>
|
<View style={inputContainerStyle}>
|
||||||
{/* 回复预览 */}
|
|
||||||
{replyingTo && (
|
{replyingTo && (
|
||||||
<ReplyPreview
|
<ReplyPreview
|
||||||
replyingTo={replyingTo}
|
replyingTo={replyingTo}
|
||||||
@@ -146,7 +179,6 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 禁言提示 */}
|
|
||||||
{isGroupChat && isMuted && (
|
{isGroupChat && isMuted && (
|
||||||
<View style={styles.mutedBanner}>
|
<View style={styles.mutedBanner}>
|
||||||
<MaterialCommunityIcons name="minus-circle" size={16} color="#FF3B30" />
|
<MaterialCommunityIcons name="minus-circle" size={16} color="#FF3B30" />
|
||||||
@@ -202,7 +234,11 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
|
|
||||||
<View style={styles.inputBox}>
|
<View style={styles.inputBox}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDisabled && styles.inputMuted]}
|
style={[
|
||||||
|
styles.input,
|
||||||
|
isDisabled && styles.inputMuted,
|
||||||
|
hasChips && styles.inputTransparent,
|
||||||
|
]}
|
||||||
placeholder={isGroupChat ? (isMuted ? (muteAll ? "全员禁言中..." : "你已被禁言...") : "发送消息,@提及成员...") : "发送消息..."}
|
placeholder={isGroupChat ? (isMuted ? (muteAll ? "全员禁言中..." : "你已被禁言...") : "发送消息,@提及成员...") : "发送消息..."}
|
||||||
placeholderTextColor={isDisabled ? textDisabled : textMuted}
|
placeholderTextColor={isDisabled ? textDisabled : textMuted}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
@@ -214,10 +250,27 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
editable={!isDisabled}
|
editable={!isDisabled}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
// 确保光标可见
|
|
||||||
cursorColor={colors.primary.main}
|
cursorColor={colors.primary.main}
|
||||||
selectionColor={`${colors.primary.main}40`}
|
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>
|
</View>
|
||||||
|
|
||||||
{canSend ? (
|
{canSend ? (
|
||||||
@@ -226,7 +279,6 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
onPress={onSend}
|
onPress={onSend}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
{/* 光泽层 - 模拟渐变效果 */}
|
|
||||||
<View style={styles.sendButtonShine} pointerEvents="none" />
|
<View style={styles.sendButtonShine} pointerEvents="none" />
|
||||||
<MaterialCommunityIcons name="send" size={18} color="#FFF" />
|
<MaterialCommunityIcons name="send" size={18} color="#FFF" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -255,4 +307,4 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChatInput;
|
export default ChatInput;
|
||||||
@@ -509,6 +509,8 @@ export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => {
|
|||||||
if (prev.currentUser !== next.currentUser) return false;
|
if (prev.currentUser !== next.currentUser) return false;
|
||||||
if (prev.otherUser !== next.otherUser) return false;
|
if (prev.otherUser !== next.otherUser) return false;
|
||||||
if (prev.messageMap !== next.messageMap) 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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -556,6 +556,29 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
|||||||
inputTransparent: {
|
inputTransparent: {
|
||||||
color: 'transparent',
|
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: {
|
inputHighlightOverlay: {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '..
|
|||||||
// 面板类型
|
// 面板类型
|
||||||
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
|
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';
|
export type MessageStatus = 'normal' | 'recalled' | 'deleted';
|
||||||
|
|
||||||
@@ -126,6 +134,7 @@ export interface ChatInputProps {
|
|||||||
onCancelReply: () => void;
|
onCancelReply: () => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
restrictionHint?: string | null;
|
restrictionHint?: string | null;
|
||||||
|
mentionChips?: MentionChipMap;
|
||||||
disableMore?: boolean;
|
disableMore?: boolean;
|
||||||
pendingAttachments?: PendingChatAttachment[];
|
pendingAttachments?: PendingChatAttachment[];
|
||||||
onRemovePendingAttachment?: (id: string) => void;
|
onRemovePendingAttachment?: (id: string) => void;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
PanelType,
|
PanelType,
|
||||||
UserRole,
|
UserRole,
|
||||||
SenderInfo,
|
SenderInfo,
|
||||||
|
MentionChipData,
|
||||||
|
MentionChipMap,
|
||||||
ChatRouteParams,
|
ChatRouteParams,
|
||||||
MenuPosition,
|
MenuPosition,
|
||||||
PendingChatAttachment,
|
PendingChatAttachment,
|
||||||
@@ -122,6 +124,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
|
|
||||||
// 本地状态
|
// 本地状态
|
||||||
const [inputText, setInputText] = useState('');
|
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 [otherUser, setOtherUser] = useState<{ id?: string; nickname?: string; avatar?: string | null } | null>(null);
|
||||||
const [isFollowedByOther, setIsFollowedByOther] = useState<boolean | null>(null);
|
const [isFollowedByOther, setIsFollowedByOther] = useState<boolean | null>(null);
|
||||||
const [currentUser, setCurrentUser] = useState<{ id?: string; nickname?: string; avatar?: string | null } | 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 [mentionQuery, setMentionQuery] = useState<string>('');
|
||||||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||||||
const [mentionAll, setMentionAll] = useState(false);
|
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 [isMuted, setIsMuted] = useState(false);
|
||||||
const [muteAll, setMuteAll] = useState(false);
|
const [muteAll, setMuteAll] = useState(false);
|
||||||
@@ -701,7 +725,52 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const handleInputChange = useCallback((text: string) => {
|
const handleInputChange = useCallback((text: string) => {
|
||||||
setInputText(text);
|
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) {
|
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('@');
|
const lastAtIndex = text.lastIndexOf('@');
|
||||||
if (lastAtIndex !== -1) {
|
if (lastAtIndex !== -1) {
|
||||||
const textAfterAt = text.slice(lastAtIndex + 1);
|
const textAfterAt = text.slice(lastAtIndex + 1);
|
||||||
@@ -714,25 +783,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
} else {
|
} else {
|
||||||
setActivePanel('none');
|
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 字段获取
|
// 【改造】获取发送者信息(群聊)- 优先从消息的 sender 字段获取
|
||||||
const getSenderInfo = useCallback((senderId: string): SenderInfo => {
|
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 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) {
|
if (lastAtIndex !== -1) {
|
||||||
const textBeforeAt = inputText.slice(0, lastAtIndex);
|
const textBeforeAt = currentText.slice(0, lastAtIndex);
|
||||||
const displayName = member.nickname || member.user?.nickname || '用户';
|
const displayName = member.nickname || member.user?.nickname || '用户';
|
||||||
const newText = `${textBeforeAt}@${displayName} `;
|
const placeholder = addMentionChip(member.user_id, displayName);
|
||||||
|
const newText = `${textBeforeAt}${placeholder} `;
|
||||||
setInputText(newText);
|
setInputText(newText);
|
||||||
|
|
||||||
if (!selectedMentions.includes(member.user_id)) {
|
|
||||||
setSelectedMentions(prev => [...prev, member.user_id]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
setMentionQuery('');
|
setMentionQuery('');
|
||||||
@@ -799,16 +849,17 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [inputText, selectedMentions]);
|
}, [addMentionChip]);
|
||||||
|
|
||||||
// @所有人
|
// @所有人
|
||||||
const handleMentionAll = useCallback(() => {
|
const handleMentionAll = useCallback(() => {
|
||||||
const lastAtIndex = inputText.lastIndexOf('@');
|
const currentText = inputTextRef.current;
|
||||||
|
const lastAtIndex = currentText.lastIndexOf('@');
|
||||||
if (lastAtIndex !== -1) {
|
if (lastAtIndex !== -1) {
|
||||||
const textBeforeAt = inputText.slice(0, lastAtIndex);
|
const textBeforeAt = currentText.slice(0, lastAtIndex);
|
||||||
const newText = `${textBeforeAt}@所有人 `;
|
const placeholder = addMentionChip('all', '所有人');
|
||||||
|
const newText = `${textBeforeAt}${placeholder} `;
|
||||||
setInputText(newText);
|
setInputText(newText);
|
||||||
setMentionAll(true);
|
|
||||||
}
|
}
|
||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
setMentionQuery('');
|
setMentionQuery('');
|
||||||
@@ -816,7 +867,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [inputText]);
|
}, [addMentionChip]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建文本消息的 segments 数组
|
* 构建文本消息的 segments 数组
|
||||||
@@ -831,45 +882,38 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mentionAll) {
|
// 按位置扫描文本,将芯片占位符转为 at segments,纯文本转为 text segments
|
||||||
segments.push({
|
let textBuffer = '';
|
||||||
type: 'at',
|
const chars = Array.from(text);
|
||||||
data: { user_id: 'all' } as AtSegmentData
|
|
||||||
});
|
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) {
|
if (textBuffer.trim()) {
|
||||||
selectedMentions.forEach(userId => {
|
segments.push({ type: 'text', data: { text: textBuffer.trim() } as TextSegmentData });
|
||||||
segments.push({
|
|
||||||
type: 'at',
|
|
||||||
data: { user_id: userId } as AtSegmentData
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let strippedText = text;
|
if (segments.length === 0 || (segments.length === 1 && segments[0].type === 'reply')) {
|
||||||
if (mentionAll) {
|
segments.push({ type: 'text', data: { text: '' } as TextSegmentData });
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}, [mentionAll, selectedMentions, groupMembers]);
|
}, [mentionChips]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建图片消息的 segments 数组
|
* 构建图片消息的 segments 数组
|
||||||
@@ -974,6 +1018,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setInputText('');
|
setInputText('');
|
||||||
setSelectedMentions([]);
|
setSelectedMentions([]);
|
||||||
setMentionAll(false);
|
setMentionAll(false);
|
||||||
|
setMentionChips(new Map());
|
||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
} else {
|
} else {
|
||||||
@@ -981,6 +1026,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setInputText('');
|
setInputText('');
|
||||||
setSelectedMentions([]);
|
setSelectedMentions([]);
|
||||||
setMentionAll(false);
|
setMentionAll(false);
|
||||||
|
setMentionChips(new Map());
|
||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
}
|
}
|
||||||
@@ -1297,17 +1343,14 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const handleAvatarLongPress = useCallback((senderId: string, nickname: string) => {
|
const handleAvatarLongPress = useCallback((senderId: string, nickname: string) => {
|
||||||
if (!isGroupChat || senderId === currentUserId) return;
|
if (!isGroupChat || senderId === currentUserId) return;
|
||||||
|
|
||||||
if (!selectedMentions.includes(senderId)) {
|
const currentText = inputTextRef.current;
|
||||||
setSelectedMentions(prev => [...prev, senderId]);
|
const placeholder = addMentionChip(senderId, nickname);
|
||||||
}
|
setInputText(currentText + `${placeholder} `);
|
||||||
|
|
||||||
const newText = inputText + `@${nickname} `;
|
|
||||||
setInputText(newText);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [isGroupChat, currentUserId, inputText, selectedMentions]);
|
}, [isGroupChat, currentUserId, addMentionChip]);
|
||||||
|
|
||||||
// 获取正在输入提示文本 - 现在从 MessageManager 获取
|
// 获取正在输入提示文本 - 现在从 MessageManager 获取
|
||||||
const getTypingHint = useCallback((): string | null => {
|
const getTypingHint = useCallback((): string | null => {
|
||||||
@@ -1410,6 +1453,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
currentUserRole,
|
currentUserRole,
|
||||||
mentionQuery,
|
mentionQuery,
|
||||||
selectedMentions,
|
selectedMentions,
|
||||||
|
mentionChips,
|
||||||
isMuted,
|
isMuted,
|
||||||
muteAll,
|
muteAll,
|
||||||
followRestrictionHint,
|
followRestrictionHint,
|
||||||
|
|||||||
Reference in New Issue
Block a user