/** * PostMentionInput - 帖子内容输入框(支持 @提及) * 复用关注列表加载逻辑 * 检测输入中的 @ 字符,弹出关注者列表供选择 */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { View, TextInput, ScrollView, TouchableOpacity, StyleSheet, Platform, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import Text from '../common/Text'; import Avatar from '../common/Avatar'; import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme'; import { useAuthStore } from '../../stores'; import { authService } from '../../services/auth'; import { postService } from '../../services/post/postService'; import { MessageSegment, AtSegmentData } from '../../types'; interface MentionUser { id: string; nickname: string; avatar: string | null; } interface SuggestPost { id: string; title: string; channel?: string; } type SuggestionMode = 'none' | 'mention' | 'postref'; interface PostMentionInputProps { value: string; onChangeText: (text: string) => void; onSegmentsChange: (segments: MessageSegment[]) => void; placeholder?: string; maxLength?: number; style?: any; minHeight?: number; autoExpand?: boolean; onPostRefPasted?: (postId: string, title: string) => void; onSubmitEditing?: () => void; returnKeyType?: 'default' | 'send' | 'done'; } const PostMentionInput: React.FC = ({ value, onChangeText, onSegmentsChange, placeholder = '说点什么...', maxLength = 2000, style, minHeight = 100, autoExpand = false, onPostRefPasted, onSubmitEditing, returnKeyType = 'default', }) => { const colors = useAppColors(); const currentUser = useAuthStore(s => s.currentUser); const [followingUsers, setFollowingUsers] = useState([]); const [suggestPosts, setSuggestPosts] = useState([]); const [suggestionMode, setSuggestionMode] = useState('none'); const [mentionQuery, setMentionQuery] = useState(''); const [mentionStartIndex, setMentionStartIndex] = useState(-1); const [loaded, setLoaded] = useState(false); const [postSearchTimer, setPostSearchTimer] = useState | null>(null); const inputRef = useRef(null); useEffect(() => { if (!loaded && currentUser?.id) { loadFollowing(); setLoaded(true); } }, [currentUser?.id, loaded]); const loadFollowing = async () => { if (!currentUser?.id) return; try { const list = await authService.getFollowingList(currentUser.id, 1, 200); setFollowingUsers( list.map(u => ({ id: u.id, nickname: u.nickname || u.username || '', avatar: u.avatar || null, })) ); } catch (_) { // ignore } }; const filteredUsers = followingUsers.filter(u => u.nickname.toLowerCase().includes(mentionQuery.toLowerCase()) ); const activeMentions = useRef(new Map()); const segmentsRef = useRef([]); const rebuildSegments = useCallback( (text: string) => { const mentions = activeMentions.current; if (mentions.size === 0) { onSegmentsChange(text.trim() ? [{ type: 'text', data: { text } }] : []); return; } const segments: MessageSegment[] = []; const sorted = Array.from(mentions.entries()).sort((a, b) => a[0] - b[0]); let lastEnd = 0; for (const [startIdx, mention] of sorted) { if (startIdx > lastEnd) { segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } }); } segments.push({ type: 'at', data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData, }); lastEnd = mention.endIndex; } if (lastEnd < text.length) { segments.push({ type: 'text', data: { text: text.slice(lastEnd) } }); } onSegmentsChange(segments); }, [onSegmentsChange] ); const handleChangeText = useCallback( (text: string) => { onChangeText(text); const cursorPos = text.length; // Check for # trigger (post ref search) let hashStart = -1; for (let i = cursorPos - 1; i >= 0; i--) { if (text[i] === '#') { hashStart = i; break; } if (text[i] === ' ' || text[i] === '\n') { break; } } if (hashStart >= 0) { const query = text.slice(hashStart + 1, cursorPos); if (query.length >= 1 && !query.includes(' ') && !query.includes('\n')) { if (postSearchTimer) { clearTimeout(postSearchTimer); } const timer = setTimeout(async () => { try { const results = await postService.suggestPosts(query, 8); setSuggestPosts(results); setSuggestionMode('postref'); setMentionStartIndex(hashStart); } catch { // ignore } }, 300); setPostSearchTimer(timer); return; } } // Check for @ trigger (mention) let atStart = -1; for (let i = cursorPos - 1; i >= 0; i--) { if (text[i] === '@') { atStart = i; break; } if (text[i] === ' ' || text[i] === '\n') { break; } } if (atStart >= 0) { const query = text.slice(atStart + 1, cursorPos); if (!query.includes(' ') && !query.includes('\n')) { setMentionQuery(query); setMentionStartIndex(atStart); setSuggestionMode('mention'); return; } } setSuggestionMode('none'); rebuildSegments(text); }, [onChangeText, rebuildSegments, postSearchTimer], ); const handleSelectMention = useCallback( (mentionUser: MentionUser) => { if (mentionStartIndex < 0) return; const before = value.slice(0, mentionStartIndex); const mentionText = `@${mentionUser.nickname} `; const newText = before + mentionText; activeMentions.current.set(mentionStartIndex, { endIndex: before.length + mentionText.length, userId: mentionUser.id, nickname: mentionUser.nickname, }); onChangeText(newText); setSuggestionMode('none'); setMentionQuery(''); setMentionStartIndex(-1); rebuildSegments(newText); inputRef.current?.focus(); }, [value, mentionStartIndex, onChangeText, rebuildSegments], ); const handleSelectPost = useCallback( (post: SuggestPost) => { if (mentionStartIndex < 0) return; const before = value.slice(0, mentionStartIndex); const refText = `#${post.title} `; const newText = before + refText; onChangeText(newText); setSuggestionMode('none'); setSuggestPosts([]); setMentionStartIndex(-1); if (onPostRefPasted) { onPostRefPasted(post.id, post.title); } rebuildSegments(newText); inputRef.current?.focus(); }, [value, mentionStartIndex, onChangeText, rebuildSegments, onPostRefPasted], ); const renderMentionItem = ({ item }: { item: MentionUser }) => ( handleSelectMention(item)} activeOpacity={0.7} > {item.nickname} 点击提及 ); return ( {suggestionMode === 'mention' && filteredUsers.length > 0 && ( {filteredUsers.map(item => ( {renderMentionItem({ item })} ))} )} {suggestionMode === 'mention' && filteredUsers.length === 0 && ( 没有找到匹配的关注用户 )} {suggestionMode === 'postref' && suggestPosts.length > 0 && ( {suggestPosts.map(item => ( handleSelectPost(item)} activeOpacity={0.7} > {item.title} 点击引用帖子 ))} )} {suggestionMode === 'postref' && suggestPosts.length === 0 && ( 没有找到匹配的帖子 )} ); }; const styles = StyleSheet.create({ container: { flexGrow: 1, }, input: { fontSize: fontSizes.md, padding: spacing.sm, borderRadius: borderRadius.md, minHeight: 100, textAlignVertical: 'top' as const, }, inputAutoExpand: { borderRadius: 0, paddingHorizontal: 0, paddingTop: spacing.sm, paddingBottom: spacing.sm, }, mentionPanel: { borderWidth: 1, borderRadius: borderRadius.md, maxHeight: 200, marginTop: spacing.xs, }, mentionList: { maxHeight: 200, }, mentionItem: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderBottomWidth: StyleSheet.hairlineWidth, }, mentionInfo: { flex: 1, marginLeft: spacing.sm, justifyContent: 'center', }, mentionName: { fontSize: fontSizes.md, fontWeight: '500', }, mentionHint: { fontSize: fontSizes.xs, marginTop: 2, }, emptyText: { fontSize: fontSizes.sm, padding: spacing.md, textAlign: 'center', }, }); export default PostMentionInput;