From 31c4f3ddd37b7d81f4a5e8f6475a3ac9fe7cc09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Sun, 5 Jan 2025 17:33:27 -0300 Subject: [PATCH] useReducer in ChatProvider --- contexts/ChatContext.tsx | 269 +++++++++++++++++++++++++++------------ 1 file changed, 189 insertions(+), 80 deletions(-) diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx index fcc177b..1ac02f7 100644 --- a/contexts/ChatContext.tsx +++ b/contexts/ChatContext.tsx @@ -14,7 +14,15 @@ import { RelatedPerson, } from "@medplum/fhirtypes"; import { useMedplum, useSubscription } from "@medplum/react-hooks"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useState, +} from "react"; import type { ChatMessage, Thread } from "@/types/chat"; import { syncResourceArray } from "@/utils/array"; @@ -179,6 +187,32 @@ async function createThreadMessage({ } as Communication); } +// State type +interface ChatState { + threads: Communication[]; + threadCommMap: Map; + currentThreadId: string | null; + message: string; + isLoadingThreads: boolean; + isLoadingMessages: boolean; +} + +// Action types +type ChatAction = + | { + type: "SET_THREADS"; + payload: { threads: Communication[]; threadCommMap: Map }; + } + | { type: "RECEIVE_MESSAGE"; payload: { threadId: string; message: Communication } } + | { type: "SEND_MESSAGE"; payload: { threadId: string; message: Communication } } + | { type: "UPDATE_MESSAGE"; payload: { threadId: string; message: Communication } } + | { type: "SELECT_THREAD"; payload: { threadId: string } } + | { type: "SET_MESSAGE_TEXT"; payload: string } + | { type: "CLEAR_THREAD_DATA" } + | { type: "SET_LOADING_THREADS"; payload: boolean } + | { type: "SET_LOADING_MESSAGES"; payload: boolean } + | { type: "CREATE_THREAD"; payload: { thread: Communication } }; + interface ChatContextType { // Thread state threads: Thread[]; @@ -210,6 +244,68 @@ interface ChatProviderProps { onError?: (error: Error) => void; } +function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case "SET_THREADS": + return { + ...state, + threads: action.payload.threads, + threadCommMap: action.payload.threadCommMap, + }; + case "RECEIVE_MESSAGE": + case "SEND_MESSAGE": + case "UPDATE_MESSAGE": { + const { threadId, message } = action.payload; + const existing = state.threadCommMap.get(threadId) || []; + const updatedMap = new Map([ + ...state.threadCommMap, + [threadId, syncResourceArray(existing, message)], + ]); + return { + ...state, + threadCommMap: updatedMap, + }; + } + case "SELECT_THREAD": + return { + ...state, + currentThreadId: action.payload.threadId, + }; + case "SET_MESSAGE_TEXT": + return { + ...state, + message: action.payload, + }; + case "CLEAR_THREAD_DATA": + return { + ...state, + threads: [], + threadCommMap: new Map(), + currentThreadId: null, + message: "", + }; + case "SET_LOADING_THREADS": + return { + ...state, + isLoadingThreads: action.payload, + }; + case "SET_LOADING_MESSAGES": + return { + ...state, + isLoadingMessages: action.payload, + }; + case "CREATE_THREAD": + return { + ...state, + threads: [...state.threads, action.payload.thread], + threadCommMap: new Map([...state.threadCommMap, [action.payload.thread.id!, []]]), + }; + + default: + return state; + } +} + export function ChatProvider({ children, onMessageReceived, @@ -221,33 +317,18 @@ export function ChatProvider({ }: ChatProviderProps) { const medplum = useMedplum(); const [profile, setProfile] = useState(medplum.getProfile()); - const [threads, setThreads] = useState([]); - const [threadCommMap, setThreadCommMap] = useState>(new Map()); - const [currentThreadId, setCurrentThreadId] = useState(null); - const [message, setMessage] = useState(""); const [reconnecting, setReconnecting] = useState(false); const [connectedOnce, setConnectedOnce] = useState(false); - const [isLoadingThreads, setIsLoadingThreads] = useState(true); - const [isLoadingMessages, setIsLoadingMessages] = useState(false); - - // Threads - const threadsOut = useMemo(() => { - if (!profile) return []; - return threads - .map((thread) => communicationToThread(profile, thread, threadCommMap.get(thread.id!) || [])) - .sort((a, b) => b.threadOrder - a.threadOrder); - }, [threads, profile, threadCommMap]); - // Thread messages - const threadMessagesOut = useMemo(() => { - if (!currentThreadId) return []; - return ( - threadCommMap - .get(currentThreadId) - ?.map(communicationToMessage) - .sort((a, b) => a.messageOrder - b.messageOrder) || [] - ); - }, [currentThreadId, threadCommMap]); + // State and dispatch + const [state, dispatch] = useReducer(chatReducer, { + threads: [], + threadCommMap: new Map(), + currentThreadId: null, + message: "", + isLoadingThreads: true, + isLoadingMessages: false, + }); // Profile reference string const profileRefStr = useMemo( @@ -274,44 +355,67 @@ export function ChatProvider({ [subscriptionQuery], ); - // Function to fetch threads with error handling + // Fetch functions const refreshThreads = useCallback(async () => { if (!profile) return; try { - setIsLoadingThreads(true); + dispatch({ type: "SET_LOADING_THREADS", payload: true }); const { threads, threadCommMap } = await fetchThreads({ medplum, threadsQuery }); - setThreads(threads); - setThreadCommMap(threadCommMap); + dispatch({ type: "SET_THREADS", payload: { threads, threadCommMap } }); } catch (err) { onError?.(err as Error); } finally { - setIsLoadingThreads(false); + dispatch({ type: "SET_LOADING_THREADS", payload: false }); } }, [medplum, profile, threadsQuery, onError]); - // Fetch communications for current thread and update received timestamp const receiveThreadCommunications = useCallback( async (threadId: string) => { try { - setIsLoadingMessages(true); - let threadComms = await fetchThreadCommunications({ medplum, threadId: threadId }); + dispatch({ type: "SET_LOADING_MESSAGES", payload: true }); + let threadComms = await fetchThreadCommunications({ medplum, threadId }); threadComms = await updateUnreceivedCommunications({ medplum, communications: threadComms, }); - setThreadCommMap((prev) => { - return new Map([...prev, [threadId, threadComms]]); + dispatch({ + type: "SET_THREADS", + payload: { + threads: state.threads, + threadCommMap: new Map([...state.threadCommMap, [threadId, threadComms]]), + }, }); } catch (err) { onError?.(err as Error); } finally { - setIsLoadingMessages(false); + dispatch({ type: "SET_LOADING_MESSAGES", payload: false }); } }, - [medplum, onError], + [medplum, onError, state.threadCommMap, state.threads], ); + // Threads + const threadsOut = useMemo(() => { + if (!profile) return []; + return state.threads + .map((thread) => + communicationToThread(profile, thread, state.threadCommMap.get(thread.id!) || []), + ) + .sort((a, b) => b.threadOrder - a.threadOrder); + }, [state.threads, profile, state.threadCommMap]); + + // Thread messages + const threadMessagesOut = useMemo(() => { + if (!state.currentThreadId) return []; + return ( + state.threadCommMap + .get(state.currentThreadId) + ?.map(communicationToMessage) + .sort((a, b) => a.messageOrder - b.messageOrder) || [] + ); + }, [state.currentThreadId, state.threadCommMap]); + // Subscribe to all communication changes useSubscription( `Communication?${getQueryString(subscriptionQuery)}`, @@ -321,7 +425,10 @@ export function ChatProvider({ // If this is a thread (no partOf), update thread list if (!communication.partOf?.length) { - setThreads((prev) => syncResourceArray(prev, communication)); + dispatch({ + type: "CREATE_THREAD", + payload: { thread: communication }, + }); return; } @@ -340,15 +447,18 @@ export function ChatProvider({ )[0]; } + // Notify via callback if provided + const existing = state.threadCommMap.get(threadId)?.find((c) => c.id === communication.id); + if (existing) { + onMessageUpdated?.(communication); + } else if (isIncoming) { + onMessageReceived?.(communication); + } + // Update the thread messages - setThreadCommMap((prev) => { - const existing = prev.get(threadId) || []; - if (existing.length && existing.find((c) => c.id === communication.id)) { - onMessageUpdated?.(communication); - } else if (isIncoming) { - onMessageReceived?.(communication); - } - return new Map([...prev, [threadId, syncResourceArray(existing, communication)]]); + dispatch({ + type: "RECEIVE_MESSAGE", + payload: { threadId, message: communication }, }); }, { @@ -366,10 +476,11 @@ export function ChatProvider({ setConnectedOnce(true); } if (reconnecting) { - const refreshPromise = refreshThreads(); - if (currentThreadId) { - refreshPromise.then(() => receiveThreadCommunications(currentThreadId)); - } + refreshThreads().then(() => { + if (state.currentThreadId) { + receiveThreadCommunications(state.currentThreadId); + } + }); setReconnecting(false); } onSubscriptionConnect?.(); @@ -378,7 +489,7 @@ export function ChatProvider({ reconnecting, onSubscriptionConnect, refreshThreads, - currentThreadId, + state.currentThreadId, receiveThreadCommunications, ]), onError: useCallback((err: Error) => onError?.(err), [onError]), @@ -392,8 +503,7 @@ export function ChatProvider({ const latestProfile = medplum.getProfile(); if (profile?.id !== latestProfile?.id) { setProfile(latestProfile); - setThreadCommMap(new Map()); - setCurrentThreadId(null); + dispatch({ type: "CLEAR_THREAD_DATA" }); } }); @@ -405,7 +515,7 @@ export function ChatProvider({ // Thread selection function const selectThread = useCallback( (threadId: string) => { - setCurrentThreadId(threadId); + dispatch({ type: "SELECT_THREAD", payload: { threadId } }); receiveThreadCommunications(threadId); }, [receiveThreadCommunications], @@ -418,38 +528,36 @@ export function ChatProvider({ if (profile.resourceType !== "Patient") throw new Error("Only patients can create threads"); const newThread = await createThreadCommunication({ medplum, profile, topic }); - setThreads((prev) => [...prev, newThread]); - setThreadCommMap((prev) => { - return new Map([...prev, [newThread.id!, []]]); - }); + dispatch({ type: "CREATE_THREAD", payload: { thread: newThread } }); return newThread.id; }, [medplum, profile], ); const sendMessage = useCallback(async () => { - if (!message.trim() || !profile || !currentThreadId) return; + if (!state.message.trim() || !profile || !state.currentThreadId) return; const newCommunication = await createThreadMessage({ medplum, profile, - message, - threadId: currentThreadId, + message: state.message, + threadId: state.currentThreadId, }); - setThreadCommMap((prev) => { - const existing = prev.get(currentThreadId) || []; - return new Map([...prev, [currentThreadId, syncResourceArray(existing, newCommunication)]]); + + dispatch({ + type: "SEND_MESSAGE", + payload: { threadId: state.currentThreadId, message: newCommunication }, }); - setMessage(""); - }, [message, profile, currentThreadId, medplum]); + dispatch({ type: "SET_MESSAGE_TEXT", payload: "" }); + }, [state.message, profile, state.currentThreadId, medplum]); const markMessageAsRead = useCallback( async (messageId: string) => { // Check if the current thread is loaded - if (!currentThreadId) return; + if (!state.currentThreadId) return; // Get the message - const threadComms = threadCommMap.get(currentThreadId) || []; + const threadComms = state.threadCommMap.get(state.currentThreadId) || []; const message = threadComms.find((c) => c.id === messageId); if (!message) return; @@ -464,29 +572,30 @@ export function ChatProvider({ const updatedCommunication = await medplum.patchResource("Communication", messageId, [ { op: "add", path: "/status", value: "completed" }, ]); - setThreadCommMap((prev) => { - const existing = prev.get(currentThreadId) || []; - return new Map([ - ...prev, - [currentThreadId, syncResourceArray(existing, updatedCommunication)], - ]); + + dispatch({ + type: "UPDATE_MESSAGE", + payload: { threadId: state.currentThreadId, message: updatedCommunication }, }); + + // Notify via callback if provided + onMessageUpdated?.(updatedCommunication); }, - [currentThreadId, threadCommMap, medplum, profileRefStr], + [state.currentThreadId, state.threadCommMap, medplum, profileRefStr, onMessageUpdated], ); const value = { threads: threadsOut, - isLoadingThreads, - isLoadingMessages, + isLoadingThreads: state.isLoadingThreads, + isLoadingMessages: state.isLoadingMessages, connectedOnce, reconnecting, createThread, - currentThreadId, + currentThreadId: state.currentThreadId, selectThread, threadMessages: threadMessagesOut, - message, - setMessage, + message: state.message, + setMessage: (message: string) => dispatch({ type: "SET_MESSAGE_TEXT", payload: message }), sendMessage, markMessageAsRead, };