Files
frontend/src/components/business/PostMentionInput.tsx
lan 7f606e14dd
All checks were successful
Frontend CI / ota-android (push) Successful in 1m21s
Frontend CI / ota-ios (push) Successful in 1m28s
Frontend CI / build-and-push-web (push) Successful in 2m56s
Frontend CI / build-android-apk (push) Successful in 2h32m37s
fix(ui): adjust keyboard handling and prevent duplicate view recording
- Fix keyboard avoidance logic in PostDetailScreen and ChatScreen by applying manual keyboard height offsets only on Android, preventing double padding on iOS.
- Prevent multiple view recording calls in TradeDetailScreen by using a ref to track if the view has already been recorded.
2026-05-06 12:53:39 +08:00

419 lines
12 KiB
TypeScript

/**
* 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<PostMentionInputProps> = ({
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<MentionUser[]>([]);
const [suggestPosts, setSuggestPosts] = useState<SuggestPost[]>([]);
const [suggestionMode, setSuggestionMode] = useState<SuggestionMode>('none');
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [loaded, setLoaded] = useState(false);
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<TextInput>(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<number, { endIndex: number; userId: string; nickname: string }>());
const segmentsRef = useRef<MessageSegment[]>([]);
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 }) => (
<TouchableOpacity
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
onPress={() => handleSelectMention(item)}
activeOpacity={0.7}
>
<Avatar source={item.avatar} size={36} name={item.nickname} />
<View style={styles.mentionInfo}>
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
{item.nickname}
</Text>
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
</Text>
</View>
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
</TouchableOpacity>
);
return (
<View style={[styles.container, style]}>
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
multiline
textAlignVertical="top"
scrollEnabled={!autoExpand}
returnKeyType={returnKeyType}
onSubmitEditing={onSubmitEditing}
blurOnSubmit={false}
style={[
styles.input,
autoExpand && styles.inputAutoExpand,
{
color: colors.text.primary,
backgroundColor: autoExpand ? 'transparent' : colors.background.default,
minHeight,
},
]}
/>
{suggestionMode === 'mention' && filteredUsers.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<ScrollView
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
>
{filteredUsers.map(item => (
<React.Fragment key={item.id}>
{renderMentionItem({ item })}
</React.Fragment>
))}
</ScrollView>
</View>
)}
{suggestionMode === 'mention' && filteredUsers.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
{suggestionMode === 'postref' && suggestPosts.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<ScrollView
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
>
{suggestPosts.map(item => (
<TouchableOpacity
key={item.id}
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
onPress={() => handleSelectPost(item)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="file-document-outline" size={18} color={colors.primary.main} />
<View style={styles.mentionInfo}>
<Text style={[styles.mentionName, { color: colors.text.primary }]} numberOfLines={1}>
{item.title}
</Text>
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
</Text>
</View>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
{suggestionMode === 'postref' && suggestPosts.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
</View>
);
};
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;