20260709212224
This commit is contained in:
1 parent
d080a2a48d
commit
dadbc304da
2 files changed
+79
-36
No files matched your search
@@ -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({
|
||||
<>
|
||||
<PressableFeedback
|
||||
delayLongPress={260}
|
||||
onLongPress={() => canShowActions && setShowActions(true)}
|
||||
onLongPress={(e: any) => {
|
||||
if (!canShowActions) return;
|
||||
const { pageX, pageY } = e.nativeEvent ?? {};
|
||||
if (pageX != null) setMenuPos({ x: pageX, y: pageY });
|
||||
setShowActions(true);
|
||||
}}
|
||||
>
|
||||
<View className={cn("px-3 py-1.5", isSelf ? "items-end" : "items-start")}>
|
||||
{!isSelf && (
|
||||
@@ -345,36 +354,38 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{showActions && (
|
||||
<Menu presentation="bottom-sheet" isOpen={showActions} onOpenChange={setShowActions}>
|
||||
<Menu.Portal>
|
||||
<Menu.Overlay />
|
||||
<Menu.Content presentation="bottom-sheet">
|
||||
{canRecall && (
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
{/* 长按操作菜单 */}
|
||||
<ContextMenu
|
||||
visible={showActions}
|
||||
position={menuPos}
|
||||
onClose={() => setShowActions(false)}
|
||||
items={[
|
||||
...(canRecall
|
||||
? [
|
||||
{
|
||||
icon: <RotateCcwIcon size={18} color="#374151" />,
|
||||
label: "撤回",
|
||||
onPress: () => {
|
||||
onRecall(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<Menu.ItemTitle>撤回</Menu.ItemTitle>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Menu.Item
|
||||
variant="danger"
|
||||
onPress={() => {
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
icon: <Trash2Icon size={18} color="#EF4444" />,
|
||||
label: "删除",
|
||||
onPress: () => {
|
||||
onDelete(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<Menu.ItemTitle>删除</Menu.ItemTitle>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)}
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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 ? (
|
||||
<View style={{ transform: [{ scaleY: -1 }] }} className="py-3 items-center">
|
||||
|
||||
@@ -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 (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
||||
<Pressable className="flex-1" onPress={onClose}>
|
||||
<View className="absolute bg-white rounded-xl overflow-hidden shadow-lg" style={{ left, top, width: menuW }}>
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View
|
||||
style={styles.backdrop}
|
||||
onStartShouldSetResponder={(e) => {
|
||||
const { pageX, pageY } = e.nativeEvent;
|
||||
// 只在点击菜单外部区域时接管响应
|
||||
return pageX < left || pageX > left + menuW || pageY < top || pageY > top + menuH;
|
||||
}}
|
||||
onResponderRelease={onClose}
|
||||
>
|
||||
{/* 视觉遮罩 */}
|
||||
<View style={StyleSheet.absoluteFillObject} className="bg-black/30" pointerEvents="none" />
|
||||
{/* 菜单 */}
|
||||
<View
|
||||
className="absolute bg-white rounded-xl overflow-hidden shadow-lg"
|
||||
style={{ left, top, width: menuW }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
@@ -37,7 +51,13 @@ export default function ContextMenu({ visible, position, items, onClose }: Conte
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user