20260626173529
This commit is contained in:
1 parent
b424369b4b
commit
5983d29128
91 files changed
+9236
-2095
No files matched your search
@@ -1,15 +0,0 @@
|
||||
import { useColorScheme } from "react-native";
|
||||
import { useAppStore } from "@/store/app";
|
||||
|
||||
function useIsDark() {
|
||||
const systemScheme = useColorScheme(); // 'light' | 'dark'
|
||||
const themeScheme = useAppStore((state) => state.themeScheme);
|
||||
|
||||
if (themeScheme === "system") {
|
||||
return systemScheme === "dark";
|
||||
}
|
||||
|
||||
return themeScheme === "dark";
|
||||
}
|
||||
|
||||
export { useIsDark };
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
||||
) {
|
||||
const theme = useColorScheme() ?? "light";
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
+97
-62
@@ -1,60 +1,47 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { useUserStore, useFamilyStore } from "@/store";
|
||||
import { useChatStore } from "@/store/chat";
|
||||
import {
|
||||
ChatMessage,
|
||||
OnlineUser,
|
||||
SendChatPayload,
|
||||
normalizeChatMessage,
|
||||
} from "@/types/chat";
|
||||
|
||||
const RECONNECT_BASE = 1000;
|
||||
const RECONNECT_MAX = 30000;
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
family_id: number;
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
content: string;
|
||||
msg_type: number;
|
||||
source_table: string | null;
|
||||
source_id: number | null;
|
||||
source_data: string | null;
|
||||
attachments: Attachment[] | null;
|
||||
recalled: number;
|
||||
deleted: number;
|
||||
create_time: string;
|
||||
export type { ChatMessage, OnlineUser };
|
||||
|
||||
interface ChatWebSocketValue {
|
||||
messages: ChatMessage[];
|
||||
onlineUsers: OnlineUser[];
|
||||
isConnected: boolean;
|
||||
unreadCount: number;
|
||||
clearUnread: () => void;
|
||||
send: (payload: SendChatPayload) => void;
|
||||
recall: (messageId: number) => void;
|
||||
deleteMessage: (messageId: number) => void;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
file_key: string;
|
||||
file_name: string;
|
||||
file_type: number;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
}
|
||||
const ChatWebSocketContext = createContext<ChatWebSocketValue | null>(null);
|
||||
|
||||
export interface OnlineUser {
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface SendPayload {
|
||||
type: "send";
|
||||
content: string;
|
||||
msg_type?: number;
|
||||
source_table?: string;
|
||||
source_id?: number;
|
||||
attachments?: Omit<Attachment, "id">[];
|
||||
}
|
||||
|
||||
export function useChatWebSocket() {
|
||||
function useChatWebSocketConnection(): ChatWebSocketValue {
|
||||
const token = useUserStore((s) => s.userInfo?.token);
|
||||
const familyId = useFamilyStore((s) => s.currentFamily?.family_id);
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const messages = useChatStore((s) => s.messages);
|
||||
const onlineUsers = useChatStore((s) => s.onlineUsers);
|
||||
const isConnected = useChatStore((s) => s.isConnected);
|
||||
const unreadCount = useChatStore((s) => s.unreadCount);
|
||||
const clearUnread = useChatStore((s) => s.clearUnread);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const lastReceivedId = useRef<number>(0);
|
||||
@@ -62,6 +49,7 @@ export function useChatWebSocket() {
|
||||
const reconnectDelay = useRef<number>(RECONNECT_BASE);
|
||||
const mounted = useRef(true);
|
||||
const wasConnected = useRef(false);
|
||||
const shouldReconnect = useRef(false);
|
||||
|
||||
const buildWsUrl = useCallback(() => {
|
||||
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "";
|
||||
@@ -80,7 +68,7 @@ export function useChatWebSocket() {
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mounted.current) return;
|
||||
setIsConnected(true);
|
||||
useChatStore.getState().setConnected(true);
|
||||
reconnectDelay.current = RECONNECT_BASE;
|
||||
if (wasConnected.current) {
|
||||
toast.success("聊天已重连");
|
||||
@@ -100,12 +88,14 @@ export function useChatWebSocket() {
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mounted.current) return;
|
||||
setIsConnected(false);
|
||||
useChatStore.getState().setConnected(false);
|
||||
wsRef.current = null;
|
||||
if (wasConnected.current) {
|
||||
toast.error("聊天已断开");
|
||||
}
|
||||
scheduleReconnect();
|
||||
if (shouldReconnect.current) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
@@ -114,7 +104,8 @@ export function useChatWebSocket() {
|
||||
}, [token, familyId, buildWsUrl]);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (!mounted.current) return;
|
||||
if (!mounted.current || !shouldReconnect.current) return;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
reconnectDelay.current = Math.min(reconnectDelay.current * 2, RECONNECT_MAX);
|
||||
connect();
|
||||
@@ -127,33 +118,29 @@ export function useChatWebSocket() {
|
||||
const data = msg.data as ChatMessage;
|
||||
if (data.id <= lastReceivedId.current) return;
|
||||
lastReceivedId.current = data.id;
|
||||
setMessages((prev) => [...prev, data]);
|
||||
useChatStore.getState().addMessage(normalizeChatMessage(data));
|
||||
break;
|
||||
}
|
||||
case "message_recalled": {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === msg.message_id ? { ...m, recalled: 1 } : m))
|
||||
);
|
||||
useChatStore.getState().recallMessage(msg.message_id);
|
||||
break;
|
||||
}
|
||||
case "message_deleted": {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== msg.message_id));
|
||||
useChatStore.getState().deleteMessage(msg.message_id);
|
||||
break;
|
||||
}
|
||||
case "online": {
|
||||
setOnlineUsers(msg.users || []);
|
||||
useChatStore.getState().setOnlineUsers(msg.users || []);
|
||||
break;
|
||||
}
|
||||
case "offline": {
|
||||
setOnlineUsers((prev) =>
|
||||
prev.filter((u) => u.user_id !== msg.user_id)
|
||||
);
|
||||
useChatStore.getState().removeOnlineUser(msg.user_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const send = useCallback((payload: SendPayload) => {
|
||||
const send = useCallback((payload: SendChatPayload) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
wsRef.current.send(JSON.stringify(payload));
|
||||
}, []);
|
||||
@@ -170,10 +157,31 @@ export function useChatWebSocket() {
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
shouldReconnect.current = !!token && !!familyId;
|
||||
|
||||
if (!token || !familyId) {
|
||||
useChatStore.getState().clear();
|
||||
lastReceivedId.current = 0;
|
||||
wasConnected.current = false;
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
useChatStore.getState().clear();
|
||||
lastReceivedId.current = 0;
|
||||
wasConnected.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
shouldReconnect.current = false;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
@@ -181,14 +189,41 @@ export function useChatWebSocket() {
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
}, [connect, token, familyId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
onlineUsers,
|
||||
isConnected,
|
||||
unreadCount,
|
||||
clearUnread,
|
||||
send,
|
||||
recall,
|
||||
deleteMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatWebSocketProvider({ children }: { children: ReactNode }) {
|
||||
const value = useChatWebSocketConnection();
|
||||
|
||||
return (
|
||||
<ChatWebSocketContext.Provider value={value}>
|
||||
{children}
|
||||
</ChatWebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatWebSocket() {
|
||||
const context = useContext(ChatWebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error("useChatWebSocket must be used within ChatWebSocketProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -6,6 +6,13 @@ interface StopOptions {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface RunLoadingOptions {
|
||||
loadingMessage?: string;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
showSuccess?: boolean;
|
||||
}
|
||||
|
||||
export function useLoading() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const toastIdRef = useRef<string | number | null>(null);
|
||||
@@ -39,7 +46,7 @@ export function useLoading() {
|
||||
successMessage?: string,
|
||||
errorMessage?: string
|
||||
): Promise<T> => {
|
||||
const id = startLoading(loadingMessage);
|
||||
startLoading(loadingMessage);
|
||||
try {
|
||||
const result = await promise;
|
||||
stopLoading({ type: "success", message: successMessage || "操作成功" });
|
||||
@@ -52,10 +59,74 @@ export function useLoading() {
|
||||
[startLoading, stopLoading]
|
||||
);
|
||||
|
||||
const runWithLoading = useCallback(
|
||||
async <T,>(task: () => Promise<T>, options?: RunLoadingOptions): Promise<T | undefined> => {
|
||||
if (isLoading) return undefined;
|
||||
|
||||
startLoading(options?.loadingMessage || "加载中...");
|
||||
try {
|
||||
const result = await task();
|
||||
if (options?.showSuccess) {
|
||||
stopLoading({ type: "success", message: options.successMessage });
|
||||
} else {
|
||||
stopLoading();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
stopLoading({ type: "error", message: options?.errorMessage || "操作失败" });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[isLoading, startLoading, stopLoading],
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
withLoading,
|
||||
runWithLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useListLoading() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const runInitial = useCallback(async <T,>(task: () => Promise<T>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runRefresh = useCallback(async <T,>(task: () => Promise<T>) => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runLoadMore = useCallback(async <T,>(task: () => Promise<T>) => {
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
refreshing,
|
||||
loadingMore,
|
||||
runInitial,
|
||||
runRefresh,
|
||||
runLoadMore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { useRouter } from "expo-router";
|
||||
import { toast } from "sonner-native";
|
||||
import { loadDataApi } from "@/request/api";
|
||||
import { useFamilyStore, useUserStore } from "@/store";
|
||||
|
||||
export function useSessionBootstrap() {
|
||||
const router = useRouter();
|
||||
const authExpired = useUserStore((s) => s.authExpired);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const userStore = useUserStore.getState();
|
||||
const familyStore = useFamilyStore.getState();
|
||||
|
||||
if (authExpired) {
|
||||
requestAnimationFrame(() => {
|
||||
userStore.clear();
|
||||
familyStore.clear();
|
||||
router.replace("/auth/login");
|
||||
});
|
||||
}
|
||||
}, [authExpired, router]);
|
||||
|
||||
return { isReady };
|
||||
}
|
||||
|
||||
export function bootstrapSession(router: ReturnType<typeof useRouter>) {
|
||||
let isMounted = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const { isLogged, userInfo } = useUserStore.getState();
|
||||
const familyStore = useFamilyStore.getState();
|
||||
|
||||
if (!isLogged()) {
|
||||
router.replace("/auth/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const checkRes = await loadDataApi({
|
||||
view_name: "b_user",
|
||||
search_condition: "id = ?",
|
||||
search_columns: ["id"],
|
||||
order_by: "",
|
||||
args: [userInfo.id],
|
||||
});
|
||||
|
||||
if (!checkRes.isSuccess || !checkRes.data || checkRes.data.length === 0) {
|
||||
toast.error("账号不存在或已被禁用");
|
||||
useUserStore.getState().clear();
|
||||
familyStore.clear();
|
||||
router.replace("/auth/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await loadDataApi({
|
||||
view_name: "b_family_member",
|
||||
search_condition: "user_id = ?",
|
||||
search_columns: ["family_id"],
|
||||
order_by: "",
|
||||
args: [userInfo.id],
|
||||
});
|
||||
|
||||
if (!res.isSuccess) {
|
||||
throw new Error("获取家庭信息失败");
|
||||
}
|
||||
|
||||
if (res.data.length === 0) {
|
||||
familyStore.clear();
|
||||
toast.warning("请先创建或加入家庭");
|
||||
router.replace("/family/entry");
|
||||
return;
|
||||
}
|
||||
|
||||
familyStore.setCurrentFamily({
|
||||
family_id: res.data[0].family_id,
|
||||
});
|
||||
|
||||
router.replace("/home");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("初始化失败,请稍后重试");
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
await SplashScreen.hideAsync();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user