diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index 75f80a1a..f75b4a52 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -5,15 +5,18 @@ import YifudapeiIcon from "@/static/images/subapps/yifudapei.webp"; import { Image, ImageSourcePropType, Text, TextInput, TouchableOpacity, View } from "react-native"; import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { useRouter } from "expo-router"; -import { Menu, PressableFeedback, Spinner } from "heroui-native"; +import { PressableFeedback, Spinner } from "heroui-native"; import { AlertCircleIcon, PaperclipIcon, PlayIcon, + RotateCcwIcon, SearchIcon, SendHorizonalIcon, + Trash2Icon, } from "lucide-react-native"; import { toast } from "sonner-native"; +import ContextMenu from "@/components/ContextMenu"; import LayoutView from "@/components/layout/view"; import EmptyState from "@/components/EmptyStatus"; import { loadDataApi, loadDataPageApi } from "@/request/api"; @@ -150,6 +153,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ const member = members[item.user_id]; const nickname = item.nickname || member?.nickname || "未知"; const [showActions, setShowActions] = useState(false); + const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); const msgTime = new Date(item.create_time).getTime(); const within2Min = !isNaN(msgTime) && Date.now() - msgTime < 2 * 60 * 1000; const canRecall = isSelf && within2Min; @@ -189,7 +193,12 @@ const ChatMessageItem = memo(function ChatMessageItem({ <> canShowActions && setShowActions(true)} + onLongPress={(e: any) => { + if (!canShowActions) return; + const { pageX, pageY } = e.nativeEvent ?? {}; + if (pageX != null) setMenuPos({ x: pageX, y: pageY }); + setShowActions(true); + }} > {!isSelf && ( @@ -345,36 +354,38 @@ const ChatMessageItem = memo(function ChatMessageItem({ - {showActions && ( - - - - - {canRecall && ( - { + {/* 长按操作菜单 */} + setShowActions(false)} + items={[ + ...(canRecall + ? [ + { + icon: , + label: "撤回", + onPress: () => { onRecall(item.id); setShowActions(false); - }} - > - 撤回 - - )} - {isAdmin && ( - { + }, + }, + ] + : []), + ...(isAdmin + ? [ + { + icon: , + label: "删除", + onPress: () => { onDelete(item.id); setShowActions(false); - }} - > - 删除 - - )} - - - - )} + }, + }, + ] + : []), + ]} + /> ); }); @@ -640,6 +651,17 @@ export default function ChatPage() { return p ? p.localId : String(item.id); }, []); + // 删除 & 撤回时同步更新本地 history,确保页面即时移除 + const handleDeleteLocal = useCallback((id: number) => { + ws.deleteMessage(id); + setHistory((prev) => prev.filter((m) => m.id !== id)); + }, [ws]); + + const handleRecallLocal = useCallback((id: number) => { + ws.recall(id); + setHistory((prev) => prev.map((m) => (m.id === id ? { ...m, recalled: 1 } : m))); + }, [ws]); + const renderItem = useCallback(({ item }: { item: any }) => { const pending = item._pending as PendingMsg | undefined; if (pending) { @@ -671,11 +693,11 @@ export default function ChatPage() { isSelf={item.user_id === userId} members={members} isAdmin={adminRole === 0} - onRecall={ws.recall} - onDelete={ws.deleteMessage} + onRecall={handleRecallLocal} + onDelete={handleDeleteLocal} /> ); - }, [userId, members, adminRole, ws.recall, ws.deleteMessage, handleRetry]); + }, [userId, members, adminRole, handleRecallLocal, handleDeleteLocal, handleRetry]); if (loading) { return ( @@ -734,6 +756,7 @@ export default function ChatPage() { if (hasMore && !loadingMore) handleLoadMore(); }} onEndReachedThreshold={0.3} + windowSize={5} ListFooterComponent={ hasMore ? ( diff --git a/code/app/app-rn/src/components/ContextMenu.tsx b/code/app/app-rn/src/components/ContextMenu.tsx index 73ab1e6f..b21c5ce4 100644 --- a/code/app/app-rn/src/components/ContextMenu.tsx +++ b/code/app/app-rn/src/components/ContextMenu.tsx @@ -1,4 +1,4 @@ -import { Dimensions, Modal, Pressable, Text, TouchableOpacity, View } from "react-native"; +import { Dimensions, Modal, StyleSheet, Text, TouchableOpacity, View } from "react-native"; export interface ContextMenuItem { icon: React.ReactNode; @@ -22,9 +22,23 @@ export default function ContextMenu({ visible, position, items, onClose }: Conte const top = Math.max(8, Math.min(position.y - menuH + 20, sh - menuH - 8)); return ( - - - + + { + const { pageX, pageY } = e.nativeEvent; + // 只在点击菜单外部区域时接管响应 + return pageX < left || pageX > left + menuW || pageY < top || pageY > top + menuH; + }} + onResponderRelease={onClose} + > + {/* 视觉遮罩 */} + + {/* 菜单 */} + {items.map((item, index) => ( ))} - + ); } + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + }, +});