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 { Image, ImageSourcePropType, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Menu, PressableFeedback, Spinner } from "heroui-native";
|
import { PressableFeedback, Spinner } from "heroui-native";
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
|
RotateCcwIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SendHorizonalIcon,
|
SendHorizonalIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import ContextMenu from "@/components/ContextMenu";
|
||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import EmptyState from "@/components/EmptyStatus";
|
import EmptyState from "@/components/EmptyStatus";
|
||||||
import { loadDataApi, loadDataPageApi } from "@/request/api";
|
import { loadDataApi, loadDataPageApi } from "@/request/api";
|
||||||
@@ -150,6 +153,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
const member = members[item.user_id];
|
const member = members[item.user_id];
|
||||||
const nickname = item.nickname || member?.nickname || "未知";
|
const nickname = item.nickname || member?.nickname || "未知";
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
|
||||||
const msgTime = new Date(item.create_time).getTime();
|
const msgTime = new Date(item.create_time).getTime();
|
||||||
const within2Min = !isNaN(msgTime) && Date.now() - msgTime < 2 * 60 * 1000;
|
const within2Min = !isNaN(msgTime) && Date.now() - msgTime < 2 * 60 * 1000;
|
||||||
const canRecall = isSelf && within2Min;
|
const canRecall = isSelf && within2Min;
|
||||||
@@ -189,7 +193,12 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
<>
|
<>
|
||||||
<PressableFeedback
|
<PressableFeedback
|
||||||
delayLongPress={260}
|
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")}>
|
<View className={cn("px-3 py-1.5", isSelf ? "items-end" : "items-start")}>
|
||||||
{!isSelf && (
|
{!isSelf && (
|
||||||
@@ -345,36 +354,38 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
<PressableFeedback.Ripple />
|
<PressableFeedback.Ripple />
|
||||||
</PressableFeedback>
|
</PressableFeedback>
|
||||||
|
|
||||||
{showActions && (
|
{/* 长按操作菜单 */}
|
||||||
<Menu presentation="bottom-sheet" isOpen={showActions} onOpenChange={setShowActions}>
|
<ContextMenu
|
||||||
<Menu.Portal>
|
visible={showActions}
|
||||||
<Menu.Overlay />
|
position={menuPos}
|
||||||
<Menu.Content presentation="bottom-sheet">
|
onClose={() => setShowActions(false)}
|
||||||
{canRecall && (
|
items={[
|
||||||
<Menu.Item
|
...(canRecall
|
||||||
onPress={() => {
|
? [
|
||||||
|
{
|
||||||
|
icon: <RotateCcwIcon size={18} color="#374151" />,
|
||||||
|
label: "撤回",
|
||||||
|
onPress: () => {
|
||||||
onRecall(item.id);
|
onRecall(item.id);
|
||||||
setShowActions(false);
|
setShowActions(false);
|
||||||
}}
|
},
|
||||||
>
|
},
|
||||||
<Menu.ItemTitle>撤回</Menu.ItemTitle>
|
]
|
||||||
</Menu.Item>
|
: []),
|
||||||
)}
|
...(isAdmin
|
||||||
{isAdmin && (
|
? [
|
||||||
<Menu.Item
|
{
|
||||||
variant="danger"
|
icon: <Trash2Icon size={18} color="#EF4444" />,
|
||||||
onPress={() => {
|
label: "删除",
|
||||||
|
onPress: () => {
|
||||||
onDelete(item.id);
|
onDelete(item.id);
|
||||||
setShowActions(false);
|
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);
|
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 renderItem = useCallback(({ item }: { item: any }) => {
|
||||||
const pending = item._pending as PendingMsg | undefined;
|
const pending = item._pending as PendingMsg | undefined;
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -671,11 +693,11 @@ export default function ChatPage() {
|
|||||||
isSelf={item.user_id === userId}
|
isSelf={item.user_id === userId}
|
||||||
members={members}
|
members={members}
|
||||||
isAdmin={adminRole === 0}
|
isAdmin={adminRole === 0}
|
||||||
onRecall={ws.recall}
|
onRecall={handleRecallLocal}
|
||||||
onDelete={ws.deleteMessage}
|
onDelete={handleDeleteLocal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [userId, members, adminRole, ws.recall, ws.deleteMessage, handleRetry]);
|
}, [userId, members, adminRole, handleRecallLocal, handleDeleteLocal, handleRetry]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -734,6 +756,7 @@ export default function ChatPage() {
|
|||||||
if (hasMore && !loadingMore) handleLoadMore();
|
if (hasMore && !loadingMore) handleLoadMore();
|
||||||
}}
|
}}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
|
windowSize={5}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
hasMore ? (
|
hasMore ? (
|
||||||
<View style={{ transform: [{ scaleY: -1 }] }} className="py-3 items-center">
|
<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 {
|
export interface ContextMenuItem {
|
||||||
icon: React.ReactNode;
|
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));
|
const top = Math.max(8, Math.min(position.y - menuH + 20, sh - menuH - 8));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<Pressable className="flex-1" onPress={onClose}>
|
<View
|
||||||
<View className="absolute bg-white rounded-xl overflow-hidden shadow-lg" style={{ left, top, width: menuW }}>
|
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) => (
|
{items.map((item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={index}
|
key={index}
|
||||||
@@ -37,7 +51,13 @@ export default function ContextMenu({ visible, position, items, onClose }: Conte
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user