20260625215406
This commit is contained in:
1 parent
2e928710df
commit
5e6f9252d5
9 files changed
+295
-235
No files matched your search
@@ -1,38 +1,68 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Image, Text, TextInput, View, Keyboard } from "react-native";
|
||||
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Menu, PressableFeedback, Spinner } from "heroui-native";
|
||||
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import EmptyState from "@/components/EmptyStatus";
|
||||
import { loadDataApi, loadDataPageApi } from "@/request/api";
|
||||
import { useUserStore, useFamilyStore } from "@/store";
|
||||
import { useChatWebSocket, ChatMessage, OnlineUser } from "@/hooks/useChatWebSocket";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { buildFileUrl } from "@/utils/file";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FlatList, Image, Text, TextInput, View, Keyboard } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
Button,
|
||||
Spinner,
|
||||
BottomSheet,
|
||||
PressableFeedback,
|
||||
} from "heroui-native";
|
||||
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { ChatMsgType } from "@/types";
|
||||
|
||||
function formatTime(ts: string) {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts.replace(" ", "T") + "Z");
|
||||
const h = d.getHours().toString().padStart(2, "0");
|
||||
const m = d.getMinutes().toString().padStart(2, "0");
|
||||
return `${h}:${m}`;
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ====== 消息气泡 ======
|
||||
// ===================== 在线栏 =====================
|
||||
|
||||
function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-4 h-12 bg-white rounded-2xl shadow-md">
|
||||
<View className="flex-row items-center gap-3 flex-1">
|
||||
{users.slice(0, 6).map((u) => (
|
||||
<PressableFeedback
|
||||
key={u.user_id}
|
||||
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
|
||||
>
|
||||
<View>
|
||||
{u.avatar ? (
|
||||
<Image
|
||||
source={{ uri: buildFileUrl(u.avatar) }}
|
||||
className="w-7 h-7 rounded-full bg-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-7 h-7 rounded-full bg-gray-300 items-center justify-center">
|
||||
<Text className="text-xs text-white">{u.nickname?.charAt(0) || "?"}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="absolute -right-0.5 -bottom-0.5 w-3 h-3 rounded-full bg-green-500 border-2 border-white" />
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
))}
|
||||
</View>
|
||||
<PressableFeedback onPress={onSearch}>
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<SearchIcon size={18} color="#9CA3AF" />
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 消息气泡 =====================
|
||||
|
||||
function ChatMessageItem({
|
||||
item,
|
||||
isSelf,
|
||||
members,
|
||||
isAdmin,
|
||||
onRecall,
|
||||
onDelete,
|
||||
item, isSelf, members, isAdmin, onRecall, onDelete,
|
||||
}: {
|
||||
item: ChatMessage;
|
||||
isSelf: boolean;
|
||||
@@ -45,35 +75,29 @@ function ChatMessageItem({
|
||||
const member = members[item.user_id];
|
||||
const nickname = item.nickname || member?.nickname || "未知";
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
const msgTime = new Date(item.create_time.replace(" ", "T") + "Z").getTime();
|
||||
const within2Min = (Date.now() - msgTime) < 2 * 60 * 1000;
|
||||
const msgTime = new Date(item.create_time).getTime();
|
||||
const within2Min = !isNaN(msgTime) && Date.now() - msgTime < 2 * 60 * 1000;
|
||||
const canRecall = isSelf && within2Min;
|
||||
const canDelete = isAdmin;
|
||||
|
||||
// 撤回
|
||||
if (item.recalled === 1) {
|
||||
return (
|
||||
<View className="items-center py-2">
|
||||
<Text className="text-xs text-gray-400">{nickname} 撤回了一条消息</Text>
|
||||
<View className="items-center py-3 px-4">
|
||||
<Text className="text-sm text-gray-400">{nickname} 撤回了一条消息</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 系统消息
|
||||
if (item.msg_type === ChatMsgType.System) {
|
||||
return (
|
||||
<View className="items-center py-2">
|
||||
<Text className="text-xs text-gray-400">{item.content}</Text>
|
||||
<View className="items-center py-3 px-4">
|
||||
<Text className="text-sm text-gray-400">{item.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PressableFeedback
|
||||
onLongPress={() => (canRecall || canDelete) && setShowActions(true)}
|
||||
>
|
||||
<PressableFeedback onLongPress={() => (canRecall || isAdmin) && setShowActions(true)}>
|
||||
<View className={cn("px-4 py-1", isSelf ? "items-end" : "items-start")}>
|
||||
{!isSelf && (
|
||||
<PressableFeedback onPress={() => router.navigate(`/family/member/${item.user_id}` as any)}>
|
||||
@@ -82,22 +106,15 @@ function ChatMessageItem({
|
||||
</PressableFeedback>
|
||||
)}
|
||||
|
||||
{/* 文本 */}
|
||||
{item.msg_type === ChatMsgType.Text && (
|
||||
<View
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2",
|
||||
isSelf ? "bg-green-500" : "bg-white"
|
||||
)}
|
||||
>
|
||||
<View className={cn("max-w-[75%] rounded-2xl px-3 py-2", isSelf ? "bg-green-500" : "bg-white")}>
|
||||
<Text className={cn("text-base leading-5", isSelf ? "text-white" : "text-gray-900")}>
|
||||
{item.content}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
{item.msg_type === ChatMsgType.Image && item.attachments && item.attachments.length > 0 && (
|
||||
{item.msg_type === ChatMsgType.Image && item.attachments?.length && (
|
||||
<View className="flex-row flex-wrap gap-1 max-w-[75%] justify-end">
|
||||
{item.attachments.map((att, i) => (
|
||||
<Image
|
||||
@@ -110,8 +127,7 @@ function ChatMessageItem({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 视频 */}
|
||||
{item.msg_type === ChatMsgType.Video && item.attachments && item.attachments.length > 0 && (
|
||||
{item.msg_type === ChatMsgType.Video && item.attachments?.length && (
|
||||
<View className="w-48 h-32 rounded-xl bg-gray-800 items-center justify-center">
|
||||
<Text className="text-white text-2xl">▶</Text>
|
||||
{item.attachments[0].duration ? (
|
||||
@@ -122,17 +138,19 @@ function ChatMessageItem({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 文件 */}
|
||||
{item.msg_type === ChatMsgType.File && item.attachments && item.attachments.length > 0 && (
|
||||
{item.msg_type === ChatMsgType.File && item.attachments?.length && (
|
||||
<View
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2",
|
||||
isSelf ? "bg-green-500" : "bg-white"
|
||||
isSelf ? "bg-green-500" : "bg-white",
|
||||
)}
|
||||
>
|
||||
<Text className={cn(isSelf ? "text-white" : "text-gray-900")}>📎</Text>
|
||||
<View className="flex-1">
|
||||
<Text className={cn("text-sm", isSelf ? "text-white" : "text-gray-900")} numberOfLines={1}>
|
||||
<Text
|
||||
className={cn("text-sm", isSelf ? "text-white" : "text-gray-900")}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.attachments[0].file_name}
|
||||
</Text>
|
||||
<Text className={cn("text-xs", isSelf ? "text-white/70" : "text-gray-400")}>
|
||||
@@ -144,7 +162,6 @@ function ChatMessageItem({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 转发 */}
|
||||
{item.msg_type === ChatMsgType.Forward && item.source_data && (
|
||||
<View className="max-w-[75%] rounded-xl overflow-hidden bg-white border border-gray-200">
|
||||
<View className="px-3 py-2">
|
||||
@@ -154,7 +171,8 @@ function ChatMessageItem({
|
||||
try {
|
||||
const d = JSON.parse(item.source_data);
|
||||
return Object.entries(d)
|
||||
.filter(([k]) => !["id", "create_time", "update_time", "create_by", "update_by", "family_id"].includes(k))
|
||||
.filter(([k]) =>
|
||||
!["id", "create_time", "update_time", "create_by", "update_by", "family_id"].includes(k))
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" ");
|
||||
} catch {
|
||||
@@ -171,108 +189,77 @@ function ChatMessageItem({
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="text-[10px] text-gray-300 mt-0.5 mr-1">{formatTime(item.create_time)}</Text>
|
||||
<Text className="text-[10px] text-gray-300 mt-0.5 mr-1">
|
||||
{formatTime(item.create_time)}
|
||||
</Text>
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{/* 操作面板 */}
|
||||
<BottomSheet
|
||||
isOpen={showActions}
|
||||
onClose={() => setShowActions(false)}
|
||||
snapPoints={["auto"]}
|
||||
>
|
||||
<View className="px-4 py-4 gap-2">
|
||||
{canRecall && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onRecall(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onDelete(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full"
|
||||
color="danger"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button onPress={() => setShowActions(false)} className="w-full" variant="light">
|
||||
取消
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
{showActions && (
|
||||
<Menu isOpen={showActions} onOpenChange={setShowActions}>
|
||||
<Menu.Portal>
|
||||
<Menu.Overlay />
|
||||
<Menu.Content presentation="bottom-sheet">
|
||||
{canRecall && (
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
onRecall(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<Menu.ItemTitle>撤回</Menu.ItemTitle>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
onDelete(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<Menu.ItemTitle>删除</Menu.ItemTitle>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ====== 在线头像栏 ======
|
||||
function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<View className="h-12 px-4 flex-row items-center justify-between border-b border-gray-100 bg-white">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Text className="text-xs text-gray-400">{users.length} 人在线</Text>
|
||||
{users.slice(0, 6).map((u) => (
|
||||
<PressableFeedback
|
||||
key={u.user_id}
|
||||
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
|
||||
>
|
||||
<View className="items-center">
|
||||
{u.avatar ? (
|
||||
<Image source={{ uri: u.avatar }} className="w-7 h-7 rounded-full bg-gray-200" />
|
||||
) : (
|
||||
<View className="w-7 h-7 rounded-full bg-gray-300 items-center justify-center">
|
||||
<Text className="text-xs text-white">{u.nickname?.charAt(0) || "?"}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
))}
|
||||
</View>
|
||||
<PressableFeedback onPress={onSearch}>
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<SearchIcon size={18} color="#9ca3af" />
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// ===================== 页面 =====================
|
||||
|
||||
// ====== 页面 ======
|
||||
export default function ChatPage() {
|
||||
const router = useRouter();
|
||||
const userId = useUserStore((s) => s.userInfo.id);
|
||||
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
|
||||
const ws = useChatWebSocket();
|
||||
|
||||
const [history, setHistory] = useState<ChatMessage[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [members, setMembers] = useState<Record<number, { nickname: string; avatar: string; role: number }>>({});
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [adminRole, setAdminRole] = useState(0);
|
||||
const listRef = useRef<any>(null);
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const ws = useChatWebSocket();
|
||||
// 键盘弹起时自动滚到底部
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener("keyboardWillShow", () => {
|
||||
setTimeout(() => listRef.current?.scrollToEnd?.({ animated: true }), 100);
|
||||
});
|
||||
return () => show.remove();
|
||||
}, []);
|
||||
|
||||
// 成员映射
|
||||
useEffect(() => {
|
||||
if (!familyId) return;
|
||||
loadDataApi({
|
||||
view_name: "b_family_member",
|
||||
view_name: "v_family_member",
|
||||
search_condition: "family_id = ?",
|
||||
search_columns: ["user_id", "nickname", "avatar", "role"],
|
||||
args: [familyId],
|
||||
@@ -280,7 +267,7 @@ export default function ChatPage() {
|
||||
}).then((res) => {
|
||||
if (res.isSuccess && res.data) {
|
||||
const map: Record<number, any> = {};
|
||||
for (const m of res.data) map[m.user_id] = m;
|
||||
for (const r of res.data) map[r.user_id] = r;
|
||||
setMembers(map);
|
||||
const me = map[userId];
|
||||
if (me) setAdminRole(me.role ?? 99);
|
||||
@@ -302,12 +289,17 @@ export default function ChatPage() {
|
||||
search_columns: [],
|
||||
});
|
||||
if (res.isSuccess && res.data) {
|
||||
const rows = res.data.rows as ChatMessage[];
|
||||
const mediaIds = rows.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File).map((r) => r.id);
|
||||
if (mediaIds.length > 0) {
|
||||
const ordered = (res.data.rows as any[])
|
||||
.map((r) => ({ ...r, msg_type: r.type ?? r.msg_type ?? 0 } as ChatMessage))
|
||||
.reverse();
|
||||
|
||||
const mids = ordered
|
||||
.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File)
|
||||
.map((r) => r.id);
|
||||
if (mids.length) {
|
||||
const fileRes = await loadDataApi({
|
||||
view_name: "b_file",
|
||||
search_condition: `target_id IN (${mediaIds.join(",")})`,
|
||||
search_condition: `target_id IN (${mids.join(",")})`,
|
||||
order_by: "sort_order ASC",
|
||||
args: [],
|
||||
search_columns: [],
|
||||
@@ -318,33 +310,35 @@ export default function ChatPage() {
|
||||
if (!fileMap[f.target_id]) fileMap[f.target_id] = [];
|
||||
fileMap[f.target_id].push(f);
|
||||
}
|
||||
for (const r of rows) {
|
||||
for (const r of ordered) {
|
||||
if (fileMap[r.id]) (r as any).attachments = fileMap[r.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (p === 1) setHistory(rows.reverse());
|
||||
else setHistory((prev) => [...rows.reverse(), ...prev]);
|
||||
setHasMore(rows.length >= 50);
|
||||
|
||||
if (p === 1) setHistory(ordered);
|
||||
else setHistory((prev) => [...ordered, ...prev]);
|
||||
setHasMore(ordered.length >= 50);
|
||||
}
|
||||
},
|
||||
[familyId]
|
||||
[familyId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory(1).finally(() => setLoading(false));
|
||||
}, [loadHistory]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
const nextPage = page + 1;
|
||||
await loadHistory(nextPage);
|
||||
setPage(nextPage);
|
||||
const np = page + 1;
|
||||
await loadHistory(np);
|
||||
setPage(np);
|
||||
setLoadingMore(false);
|
||||
}, [loadingMore, hasMore, page, loadHistory]);
|
||||
|
||||
// 合并实时消息
|
||||
// 合并消息 (旧→新)
|
||||
const allMessages = (() => {
|
||||
const ids = new Set(history.map((m) => m.id));
|
||||
const merged = [...history];
|
||||
@@ -366,9 +360,6 @@ export default function ChatPage() {
|
||||
ws.send({ type: "send", content: text });
|
||||
}, [inputText, ws]);
|
||||
|
||||
const handleRecall = useCallback((id: number) => ws.recall(id), [ws]);
|
||||
const handleDelete = useCallback((id: number) => ws.deleteMessage(id), [ws]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<LayoutView>
|
||||
@@ -378,69 +369,79 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* 连接状态 */}
|
||||
<View className="h-7 px-4 flex-row items-center gap-1.5">
|
||||
<View className={cn("w-2 h-2 rounded-full", ws.isConnected ? "bg-green-500" : "bg-red-400")} />
|
||||
<Text className="text-xs text-gray-400">{ws.isConnected ? "已连接" : "连接中..."}</Text>
|
||||
</View>
|
||||
|
||||
{/* 在线成员 + 搜索入口 */}
|
||||
<ChatOnlineBar users={ws.onlineUsers} onSearch={() => router.navigate("/chat/history")} />
|
||||
|
||||
{/* 消息列表 */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={-60}
|
||||
>
|
||||
<LayoutView>
|
||||
<LayoutView.FlatList
|
||||
ref={listRef}
|
||||
header={
|
||||
<ChatOnlineBar
|
||||
users={ws.onlineUsers}
|
||||
onSearch={() => router.navigate("/chat/history")}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<View className="flex-row items-center gap-2 px-3 py-2 bg-white rounded-2xl shadow-md">
|
||||
<TextInput
|
||||
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-base"
|
||||
placeholder="输入消息..."
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
onSubmitEditing={handleSend}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
<PressableFeedback onPress={handleSend} isDisabled={!inputText.trim()}>
|
||||
<View
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full items-center justify-center",
|
||||
inputText.trim() ? "bg-green-500" : "bg-gray-300",
|
||||
)}
|
||||
>
|
||||
<SendHorizonalIcon size={18} color="white" />
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
}
|
||||
data={allMessages}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={200}
|
||||
onContentSizeChange={() => {
|
||||
if (loadingMore) return;
|
||||
listRef.current?.scrollToEnd({ animated: false });
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
loadingMore ? (
|
||||
<View className="py-2 items-center">
|
||||
<Spinner size="sm" />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View className="items-center py-20">
|
||||
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<ChatMessageItem
|
||||
item={item}
|
||||
isSelf={item.user_id === userId}
|
||||
members={members}
|
||||
isAdmin={adminRole === 0}
|
||||
onRecall={handleRecall}
|
||||
onDelete={handleDelete}
|
||||
onRecall={ws.recall}
|
||||
onDelete={ws.deleteMessage}
|
||||
/>
|
||||
)}
|
||||
inverted
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingVertical: 8 }}
|
||||
ListFooterComponent={
|
||||
loadingMore ? (
|
||||
<View className="py-4 items-center">
|
||||
<Spinner size="sm" />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 输入栏 */}
|
||||
<View className="flex-row items-center gap-2 px-3 py-2 bg-white border-t border-gray-200">
|
||||
<TextInput
|
||||
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-base"
|
||||
placeholder="输入消息..."
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
onSubmitEditing={handleSend}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
<PressableFeedback onPress={handleSend} disabled={!inputText.trim()}>
|
||||
<View
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full items-center justify-center",
|
||||
inputText.trim() ? "bg-green-500" : "bg-gray-300"
|
||||
)}
|
||||
>
|
||||
<SendHorizonalIcon size={18} color="white" />
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
</View>
|
||||
</LayoutView>
|
||||
</LayoutView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import React, { forwardRef, type ReactNode } from "react";
|
||||
import {
|
||||
SectionList,
|
||||
FlatList,
|
||||
@@ -37,7 +37,6 @@ function Shadow({ children }: { children: React.ReactElement }) {
|
||||
|
||||
function useContentPadding(config?: LayoutConfig) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return {
|
||||
paddingTop: insets.top + (config?.topExtraPadding ?? 50),
|
||||
paddingBottom: insets.bottom + (config?.bottomExtraPadding ?? 80),
|
||||
@@ -68,11 +67,7 @@ LayoutView.ScrollView = forwardRef<
|
||||
any,
|
||||
React.ComponentProps<typeof KeyboardAwareScrollView> & LayoutConfig
|
||||
>(({ topExtraPadding, bottomExtraPadding, contentContainerStyle, ...props }, ref) => {
|
||||
const padding = useContentPadding({
|
||||
topExtraPadding,
|
||||
bottomExtraPadding,
|
||||
});
|
||||
|
||||
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
|
||||
return (
|
||||
<Wrapper>
|
||||
<Shadow>
|
||||
@@ -91,24 +86,15 @@ LayoutView.ScrollView = forwardRef<
|
||||
|
||||
LayoutView.SectionView = forwardRef(
|
||||
<T,>(
|
||||
{
|
||||
topExtraPadding,
|
||||
bottomExtraPadding,
|
||||
contentContainerStyle,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SectionList<T>> & LayoutConfig,
|
||||
props: React.ComponentProps<typeof SectionList<T>> & LayoutConfig,
|
||||
ref: React.Ref<SectionList<T>>,
|
||||
) => {
|
||||
const { topExtraPadding, bottomExtraPadding, contentContainerStyle, ...rest } = props;
|
||||
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Shadow>
|
||||
<SectionList
|
||||
ref={ref}
|
||||
{...props}
|
||||
contentContainerStyle={[padding, contentContainerStyle]}
|
||||
/>
|
||||
<SectionList ref={ref} {...rest} contentContainerStyle={[padding, contentContainerStyle]} />
|
||||
</Shadow>
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -118,20 +104,59 @@ LayoutView.SectionView = forwardRef(
|
||||
LayoutConfig & { ref?: React.Ref<SectionList<T>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
/* ========================= FlatList ========================= */
|
||||
/* ========================= FlatList / LegendList ========================= */
|
||||
|
||||
type PinnedListConfig = {
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
LayoutView.FlatList = forwardRef(
|
||||
<T,>(
|
||||
{
|
||||
header,
|
||||
footer,
|
||||
topExtraPadding,
|
||||
bottomExtraPadding,
|
||||
contentContainerStyle,
|
||||
...props
|
||||
}: React.ComponentProps<typeof FlatList<T>> & LayoutConfig,
|
||||
}: React.ComponentProps<typeof FlatList<T>> & LayoutConfig & PinnedListConfig,
|
||||
ref: React.Ref<FlatList<T>>,
|
||||
) => {
|
||||
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
|
||||
|
||||
if (header || footer) {
|
||||
return (
|
||||
<>
|
||||
{header ? (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: padding.paddingTop,
|
||||
paddingHorizontal: padding.paddingHorizontal,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
) : null}
|
||||
<Wrapper>
|
||||
<Shadow>
|
||||
<FlatList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
|
||||
</Shadow>
|
||||
</Wrapper>
|
||||
{footer ? (
|
||||
<View
|
||||
style={{
|
||||
paddingBottom: padding.paddingBottom,
|
||||
paddingHorizontal: padding.paddingHorizontal,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Shadow>
|
||||
@@ -141,37 +166,69 @@ LayoutView.FlatList = forwardRef(
|
||||
);
|
||||
},
|
||||
) as <T>(
|
||||
props: React.ComponentProps<typeof FlatList<T>> & LayoutConfig & { ref?: React.Ref<FlatList<T>> },
|
||||
props: React.ComponentProps<typeof FlatList<T>> &
|
||||
LayoutConfig &
|
||||
PinnedListConfig & { ref?: React.Ref<FlatList<T>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
/* ========================= LegendList ========================= */
|
||||
|
||||
LayoutView.LegendList = forwardRef(
|
||||
<T,>(
|
||||
{
|
||||
header,
|
||||
footer,
|
||||
topExtraPadding,
|
||||
bottomExtraPadding,
|
||||
contentContainerStyle,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LegendList<T>> & LayoutConfig,
|
||||
}: React.ComponentProps<typeof LegendList<T>> & LayoutConfig & PinnedListConfig,
|
||||
ref: React.Ref<any>,
|
||||
) => {
|
||||
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
|
||||
|
||||
if (header || footer) {
|
||||
return (
|
||||
<>
|
||||
{header ? (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: padding.paddingTop,
|
||||
paddingHorizontal: padding.paddingHorizontal,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
) : null}
|
||||
<Wrapper>
|
||||
<Shadow>
|
||||
<LegendList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
|
||||
</Shadow>
|
||||
</Wrapper>
|
||||
{footer ? (
|
||||
<View
|
||||
style={{
|
||||
paddingBottom: padding.paddingBottom,
|
||||
paddingHorizontal: padding.paddingHorizontal,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Shadow>
|
||||
<LegendList
|
||||
ref={ref}
|
||||
{...props}
|
||||
contentContainerStyle={[padding, contentContainerStyle]}
|
||||
/>
|
||||
<LegendList ref={ref} {...props} contentContainerStyle={[padding, contentContainerStyle]} />
|
||||
</Shadow>
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
) as <T>(
|
||||
props: React.ComponentProps<typeof LegendList<T>> & LayoutConfig & { ref?: React.Ref<any> },
|
||||
props: React.ComponentProps<typeof LegendList<T>> &
|
||||
LayoutConfig &
|
||||
PinnedListConfig & { ref?: React.Ref<any> },
|
||||
) => React.ReactElement;
|
||||
|
||||
/* ========================= */
|
||||
|
||||
@@ -67,7 +67,7 @@ export function useChatWebSocket() {
|
||||
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "";
|
||||
const wsProtocol = apiUrl.startsWith("https") ? "wss" : "ws";
|
||||
const host = apiUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}://${host}/app/chat/ws?token=${token}&family_id=${familyId}`;
|
||||
return `${wsProtocol}://${host}/chat/ws?token=${token}&family_id=${familyId}`;
|
||||
}, [token, familyId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function AppLayout({ tabs }: { tabs: TabbarItem[] }) {
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ position: "absolute", left: 0, right: 0, bottom: 0 }}>
|
||||
<View style={{ paddingBottom: insets.bottom }}>
|
||||
<View style={{ paddingBottom: insets.bottom + 16 }}>
|
||||
<Tabbar
|
||||
tabs={tabs}
|
||||
value={activeTabItem?.id}
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingBottom: insets.bottom }}>
|
||||
<View style={{ paddingBottom: insets.bottom + 16 }}>
|
||||
<Tabbar tabs={tabs} value={activeTabItem?.id} onChange={(id) => router.push(id as any)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -62,8 +62,6 @@ const alovaInstance = createAlova({
|
||||
},
|
||||
|
||||
beforeRequest(method) {
|
||||
console.log(process.env.EXPO_PUBLIC_API_URL)
|
||||
|
||||
// 白名单接口直接放行
|
||||
if (isWhiteListed(method.url)) return;
|
||||
|
||||
|
||||
Reference in new issue
Block a user