From 45935b89b48856442ec70b4ad87623f2f66f8575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Fri, 31 Jan 2025 11:56:07 -0300 Subject: [PATCH 1/8] Message deletion --- __tests__/hooks/useSingleThread.tsx | 101 ++++++++++++++++++ app/(app)/thread/[id].tsx | 75 +++++++++++++- components/ChatHeader.tsx | 79 +++++++++++--- components/ChatMessageBubble.tsx | 91 +++++++++++----- components/ChatMessageInput.tsx | 9 +- components/ChatMessageList.tsx | 21 +++- components/CreateThreadModal.tsx | 155 +++++++--------------------- components/MessageDeleteModal.tsx | 39 +++++++ components/Modal.tsx | 83 +++++++++++++++ contexts/ChatContext.tsx | 34 ++++++ hooks/useSingleThread.tsx | 2 + 11 files changed, 517 insertions(+), 172 deletions(-) create mode 100644 components/MessageDeleteModal.tsx create mode 100644 components/Modal.tsx diff --git a/__tests__/hooks/useSingleThread.tsx b/__tests__/hooks/useSingleThread.tsx index bc08229..ff6f790 100644 --- a/__tests__/hooks/useSingleThread.tsx +++ b/__tests__/hooks/useSingleThread.tsx @@ -774,4 +774,105 @@ describe("useSingleThread", () => { expect(message!.read).toBe(false); }); }); + + test("deleteMessages deletes multiple messages and updates thread", async () => { + const { medplum } = await setup(); + const deleteSpy = jest.spyOn(medplum, "deleteResource"); + const patchSpy = jest.spyOn(medplum, "patchResource"); + + const { result } = renderHook(() => useSingleThread({ threadId: "test-thread" }), { + wrapper: createWrapper(medplum), + }); + + // Wait for loading to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Delete both messages + await act(async () => { + await result.current.deleteMessages({ + threadId: "test-thread", + messageIds: ["msg-1", "msg-2"], + }); + }); + + // Verify messages were deleted + expect(deleteSpy).toHaveBeenCalledTimes(2); + expect(deleteSpy).toHaveBeenCalledWith("Communication", "msg-1"); + expect(deleteSpy).toHaveBeenCalledWith("Communication", "msg-2"); + + // Verify thread last changed date was updated + expect(patchSpy).toHaveBeenCalledWith( + "Communication", + "test-thread", + expect.arrayContaining([ + expect.objectContaining({ + op: "add", + path: "/extension/0/valueDateTime", + value: expect.any(String), + }), + ]), + ); + }); + + test("deleteMessages handles errors", async () => { + const { medplum } = await setup(); + const onErrorMock = jest.fn(); + const error = new Error("Failed to delete message"); + jest.spyOn(medplum, "deleteResource").mockRejectedValue(error); + + const { result } = renderHook(() => useSingleThread({ threadId: "test-thread" }), { + wrapper: createWrapper(medplum, { onError: onErrorMock }), + }); + + // Wait for loading to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Attempt to delete messages + await act(async () => { + try { + await result.current.deleteMessages({ + threadId: "test-thread", + messageIds: ["msg-1"], + }); + fail("Expected deleteMessages to throw"); + } catch (e) { + expect(e).toBe(error); + } + }); + + // Verify error was propagated + expect(onErrorMock).toHaveBeenCalledWith(error); + }); + + test("deleteMessages does nothing if no profile", async () => { + const { medplum } = await setup(); + const deleteSpy = jest.spyOn(medplum, "deleteResource"); + + // Clear the profile + medplum.setProfile(undefined); + + const { result } = renderHook(() => useSingleThread({ threadId: "test-thread" }), { + wrapper: createWrapper(medplum), + }); + + // Wait for loading to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Attempt to delete messages + await act(async () => { + await result.current.deleteMessages({ + threadId: "test-thread", + messageIds: ["msg-1"], + }); + }); + + // Verify no deletion was attempted + expect(deleteSpy).not.toHaveBeenCalled(); + }); }); diff --git a/app/(app)/thread/[id].tsx b/app/(app)/thread/[id].tsx index ec5d545..66e1d51 100644 --- a/app/(app)/thread/[id].tsx +++ b/app/(app)/thread/[id].tsx @@ -8,6 +8,7 @@ import { ChatHeader } from "@/components/ChatHeader"; import { ChatMessageInput } from "@/components/ChatMessageInput"; import { ChatMessageList } from "@/components/ChatMessageList"; import { LoadingScreen } from "@/components/LoadingScreen"; +import { MessageDeleteModal } from "@/components/MessageDeleteModal"; import { useAvatars } from "@/hooks/useAvatars"; import { useSingleThread } from "@/hooks/useSingleThread"; @@ -43,15 +44,19 @@ async function getAttachment() { export default function ThreadPage() { const { id } = useLocalSearchParams<{ id: string }>(); const { profile } = useMedplumContext(); - const { thread, isLoadingThreads, isLoading, sendMessage, markMessageAsRead } = useSingleThread({ - threadId: id, - }); + const { thread, isLoadingThreads, isLoading, sendMessage, markMessageAsRead, deleteMessages } = + useSingleThread({ + threadId: id, + }); const { getAvatarURL, isLoading: isAvatarsLoading } = useAvatars([ thread?.getAvatarRef({ profile }), ]); const [message, setMessage] = useState(""); const [isAttaching, setIsAttaching] = useState(false); const [isSending, setIsSending] = useState(false); + const [selectedMessages, setSelectedMessages] = useState>(new Set()); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); // If thread is not loading and the thread undefined, redirect to the index page useEffect(() => { @@ -102,20 +107,80 @@ export default function ThreadPage() { } }, [thread, handleSendMessage]); + const handleMessageSelect = useCallback((messageId: string) => { + setSelectedMessages((prev) => { + const next = new Set(prev); + if (next.has(messageId)) { + next.delete(messageId); + } else { + next.add(messageId); + } + return next; + }); + }, []); + + const handleConfirmDelete = useCallback(async () => { + if (!thread) return; + setIsDeleting(true); + try { + await deleteMessages({ + threadId: thread.id, + messageIds: Array.from(selectedMessages), + }); + setSelectedMessages(new Set()); + } catch (error) { + console.error("Error deleting messages:", error); + Alert.alert("Error", "Failed to delete messages. Please try again."); + } finally { + setIsDeleting(false); + setIsDeleteModalOpen(false); + } + }, [thread, selectedMessages, deleteMessages]); + + const handleDeleteMessages = useCallback(() => { + if (!thread || selectedMessages.size === 0) return; + setIsDeleteModalOpen(true); + }, [thread, selectedMessages]); + + const handleCancelSelection = useCallback(() => { + setSelectedMessages(new Set()); + }, []); + if (!thread || isAvatarsLoading) { return ; } return ( - - + + 0} + /> 0} + /> + setIsDeleteModalOpen(false)} + onConfirm={handleConfirmDelete} + selectedCount={selectedMessages.size} + isDeleting={isDeleting} /> ); diff --git a/components/ChatHeader.tsx b/components/ChatHeader.tsx index 724feed..ee4c661 100644 --- a/components/ChatHeader.tsx +++ b/components/ChatHeader.tsx @@ -2,11 +2,12 @@ import { Patient, Practitioner } from "@medplum/fhirtypes"; import { Reference } from "@medplum/fhirtypes"; import { useMedplumContext } from "@medplum/react-hooks"; import { useRouter } from "expo-router"; -import { ChevronLeftIcon, UserRound } from "lucide-react-native"; +import { ChevronLeftIcon, TrashIcon, UserRound, XIcon } from "lucide-react-native"; import { useMemo } from "react"; import { View } from "react-native"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; +import { Button, ButtonIcon, ButtonSpinner, ButtonText } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Pressable } from "@/components/ui/pressable"; import { Text } from "@/components/ui/text"; @@ -46,6 +47,7 @@ function ChatStatus({ currentThread }: { currentThread: Thread }) { message: "A provider will respond to you soon", }; }, [currentThread, isPatient]); + return ( @@ -56,46 +58,89 @@ function ChatStatus({ currentThread }: { currentThread: Thread }) { ); } -export function ChatHeader({ - currentThread, - getAvatarURL, -}: { +export interface ChatHeaderProps { currentThread: Thread; getAvatarURL: ( reference: Reference | undefined, ) => string | null | undefined; -}) { + selectedCount?: number; + onDelete?: () => void; + onCancelSelection?: () => void; + isDeleting?: boolean; +} + +export function ChatHeader({ + currentThread, + getAvatarURL, + selectedCount = 0, + onDelete, + onCancelSelection, + isDeleting = false, +}: ChatHeaderProps) { const router = useRouter(); const { profile } = useMedplumContext(); const avatarURL = getAvatarURL(currentThread.getAvatarRef({ profile })); + const isSelectionMode = selectedCount > 0; + return ( { - if (router.canGoBack()) { + if (isSelectionMode) { + onCancelSelection?.(); + } else if (router.canGoBack()) { router.back(); } else { router.replace("/"); } }} > - + - - - - {avatarURL && } - - + {isSelectionMode ? ( + - {currentThread.topic} + {selectedCount} selected - + + + ) : ( + + + + {avatarURL && } + + + + {currentThread.topic} + + + - + )} ); diff --git a/components/ChatMessageBubble.tsx b/components/ChatMessageBubble.tsx index 8989ea2..7bc7fd1 100644 --- a/components/ChatMessageBubble.tsx +++ b/components/ChatMessageBubble.tsx @@ -20,6 +20,9 @@ import { isMediaExpired, mediaKey, shareFile } from "@/utils/media"; interface ChatMessageBubbleProps { message: ChatMessage; avatarURL?: string | null; + selected?: boolean; + onSelect?: (messageId: string) => void; + selectionEnabled?: boolean; } const mediaStyles = StyleSheet.create({ @@ -114,7 +117,13 @@ function FileAttachment({ attachment }: { attachment: AttachmentWithUrl }) { ); } -export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps) { +export function ChatMessageBubble({ + message, + avatarURL, + selected = false, + onSelect, + selectionEnabled = false, +}: ChatMessageBubbleProps) { const profile = useMedplumProfile(); const isPatientMessage = message.senderType === "Patient"; const isCurrentUser = message.senderType === profile?.resourceType; @@ -125,34 +134,66 @@ export function ChatMessageBubble({ message, avatarURL }: ChatMessageBubbleProps const bubbleColor = isPatientMessage ? "bg-secondary-200" : "bg-tertiary-200"; const borderColor = isPatientMessage ? "border-secondary-300" : "border-tertiary-300"; const flexDirection = isCurrentUser ? "flex-row-reverse" : "flex-row"; + + const handleLongPress = useCallback(() => { + if (onSelect) { + onSelect(message.id); + } + }, [message.id, onSelect]); + + const handlePress = useCallback(() => { + if (selectionEnabled && onSelect) { + onSelect(message.id); + } + }, [message.id, onSelect, selectionEnabled]); + return ( - - - - - {avatarURL && } - - - {message.attachment?.url && ( - - {hasImage ? ( - - ) : hasVideo ? ( - - ) : ( - + + {/* Selection background */} + {selected && } + + + + + + + {avatarURL && } + + + {message.attachment?.url && ( + + {hasImage ? ( + + ) : hasVideo ? ( + + ) : ( + + )} + )} + {Boolean(message.text) && {message.text}} + {formatTime(message.sentAt)} - )} - {Boolean(message.text) && {message.text}} - {formatTime(message.sentAt)} + - + ); } diff --git a/components/ChatMessageInput.tsx b/components/ChatMessageInput.tsx index a7e762b..a6ba505 100644 --- a/components/ChatMessageInput.tsx +++ b/components/ChatMessageInput.tsx @@ -10,6 +10,7 @@ interface ChatMessageInputProps { onAttachment: () => Promise; onSend: () => Promise; isSending: boolean; + disabled?: boolean; } export function ChatMessageInput({ @@ -18,6 +19,7 @@ export function ChatMessageInput({ onAttachment, onSend, isSending, + disabled = false, }: ChatMessageInputProps) { return ( @@ -25,25 +27,24 @@ export function ChatMessageInput({ variant="outline" size="md" onPress={() => onAttachment()} - disabled={isSending} + disabled={isSending || disabled} className="mr-3 aspect-square border-outline-300 p-2 disabled:bg-background-300" > - + - - - ); -} - -export function CreateThreadModal({ isOpen, onClose, onCreateThread }: CreateThreadModalProps) { +export function CreateThreadModal({ isOpen, onClose, onCreate }: CreateThreadModalProps) { const [topic, setTopic] = useState(""); const [isCreating, setIsCreating] = useState(false); + const { colorScheme } = useColorScheme(); const router = useRouter(); - const handleClose = useCallback(() => { - setTopic(""); - onClose(); - }, [onClose]); - - const handleCreate = useCallback(async () => { - if (!topic.trim() || isCreating) return; - + const handleCreate = async () => { setIsCreating(true); try { - const threadId = await onCreateThread(topic); - if (threadId) { - handleClose(); - router.push(`/thread/${threadId}`); - } + await onCreate(topic); + setTopic(""); + onClose(); + router.push(`/thread/${topic}`); } finally { setIsCreating(false); } - }, [topic, isCreating, onCreateThread, handleClose, router]); + }; - if (!isOpen) return null; + const isValid = Boolean(topic.trim()); return ( - - - true} - onTouchEnd={(e) => e.stopPropagation()} - > - - - - + Create New Thread + + + + - + - - + + + + + + ); } diff --git a/components/MessageDeleteModal.tsx b/components/MessageDeleteModal.tsx new file mode 100644 index 0000000..488eeb6 --- /dev/null +++ b/components/MessageDeleteModal.tsx @@ -0,0 +1,39 @@ +import { Modal, ModalBody, ModalFooter, ModalHeader } from "@/components/Modal"; +import { Button, ButtonText } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; + +interface MessageDeleteModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + selectedCount: number; + isDeleting: boolean; +} + +export function MessageDeleteModal({ + isOpen, + onClose, + onConfirm, + selectedCount, + isDeleting, +}: MessageDeleteModalProps) { + return ( + + Delete Messages + + + Are you sure you want to delete {selectedCount} message + {selectedCount > 1 ? "s" : ""}? + + + + + + + + ); +} diff --git a/components/Modal.tsx b/components/Modal.tsx new file mode 100644 index 0000000..ba92fd1 --- /dev/null +++ b/components/Modal.tsx @@ -0,0 +1,83 @@ +import { useCallback } from "react"; +import { BackHandler, Modal as RNModal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Text } from "@/components/ui/text"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +} + +interface ModalHeaderProps { + children: React.ReactNode; +} + +interface ModalBodyProps { + children: React.ReactNode; +} + +interface ModalFooterProps { + children: React.ReactNode; +} + +export function Modal({ isOpen, onClose, children }: ModalProps) { + const insets = useSafeAreaInsets(); + + const handleBackPress = useCallback(() => { + if (isOpen) { + onClose(); + return true; + } + return false; + }, [isOpen, onClose]); + + // Handle hardware back button on Android + useCallback(() => { + BackHandler.addEventListener("hardwareBackPress", handleBackPress); + return () => { + BackHandler.removeEventListener("hardwareBackPress", handleBackPress); + }; + }, [handleBackPress]); + + if (!isOpen) return null; + + return ( + + + + + {children} + + + + ); +} + +export function ModalHeader({ children }: ModalHeaderProps) { + return ( + + + {children} + + + ); +} + +export function ModalBody({ children }: ModalBodyProps) { + return {children}; +} + +export function ModalFooter({ children }: ModalFooterProps) { + return {children}; +} diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx index d2d4553..765a1e7 100644 --- a/contexts/ChatContext.tsx +++ b/contexts/ChatContext.tsx @@ -208,6 +208,13 @@ interface ChatContextType { threadId: string; messageId: string; }) => Promise; + deleteMessages: ({ + threadId, + messageIds, + }: { + threadId: string; + messageIds: string[]; + }) => Promise; } export const ChatContext = createContext({ @@ -220,6 +227,7 @@ export const ChatContext = createContext({ receiveThread: async () => {}, sendMessage: async () => {}, markMessageAsRead: async () => {}, + deleteMessages: async () => {}, }); interface ChatProviderProps { @@ -480,6 +488,31 @@ export function ChatProvider({ [threadCommMap, medplum, profile], ); + const deleteMessages = useCallback( + async ({ threadId, messageIds }: { threadId: string; messageIds: string[] }) => { + if (!profile) return; + + try { + // Delete each message in parallel + await Promise.all( + messageIds.map((messageId) => medplum.deleteResource("Communication", messageId)), + ); + + // Update the thread's last changed date to trigger a refresh + await touchThreadLastChanged({ + medplum, + threadId, + value: new Date().toISOString(), + }); + } catch (error) { + console.error("ChatContext: Error deleting messages:", error); + onError?.(error as Error); + throw error; + } + }, + [medplum, profile, onError], + ); + const value = { threads: threadsOut, isLoadingThreads, @@ -490,6 +523,7 @@ export function ChatProvider({ receiveThread, sendMessage, markMessageAsRead, + deleteMessages, }; return {children}; diff --git a/hooks/useSingleThread.tsx b/hooks/useSingleThread.tsx index 59a14b3..41ccceb 100644 --- a/hooks/useSingleThread.tsx +++ b/hooks/useSingleThread.tsx @@ -18,6 +18,7 @@ export function useSingleThread({ threadId }: UseSingleThreadProps) { ); const sendMessage = useContextSelector(ChatContext, (state) => state.sendMessage); const markMessageAsRead = useContextSelector(ChatContext, (state) => state.markMessageAsRead); + const deleteMessages = useContextSelector(ChatContext, (state) => state.deleteMessages); const previousReconnectingRef = useRef(false); const reconnecting = useContextSelector(ChatContext, (state) => state.reconnecting); @@ -40,5 +41,6 @@ export function useSingleThread({ threadId }: UseSingleThreadProps) { isLoading: isLoading ?? false, sendMessage, markMessageAsRead, + deleteMessages, }; } From 6665c4da337c381710dd4b719209021d61d6d0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 4 Feb 2025 11:00:37 -0300 Subject: [PATCH 2/8] Fix thread creation modal redirect --- components/CreateThreadModal.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/components/CreateThreadModal.tsx b/components/CreateThreadModal.tsx index afd1d9b..c67ccc5 100644 --- a/components/CreateThreadModal.tsx +++ b/components/CreateThreadModal.tsx @@ -10,10 +10,10 @@ import { View } from "@/components/ui/view"; interface CreateThreadModalProps { isOpen: boolean; onClose: () => void; - onCreate: (topic: string) => Promise; + onCreateThread: (topic: string) => Promise; } -export function CreateThreadModal({ isOpen, onClose, onCreate }: CreateThreadModalProps) { +export function CreateThreadModal({ isOpen, onClose, onCreateThread }: CreateThreadModalProps) { const [topic, setTopic] = useState(""); const [isCreating, setIsCreating] = useState(false); const { colorScheme } = useColorScheme(); @@ -22,10 +22,12 @@ export function CreateThreadModal({ isOpen, onClose, onCreate }: CreateThreadMod const handleCreate = async () => { setIsCreating(true); try { - await onCreate(topic); - setTopic(""); - onClose(); - router.push(`/thread/${topic}`); + const threadId = await onCreateThread(topic); + if (threadId) { + setTopic(""); + onClose(); + router.push(`/thread/${threadId}`); + } } finally { setIsCreating(false); } From 7f9e511cd832b265e6eb15715554137a5e4445be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 4 Feb 2025 11:00:54 -0300 Subject: [PATCH 3/8] Mention message deletion on README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 581a8d7..33f9340 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The app implements a secure live chat system following Medplum's ["Organizing Co - Send chat messages by creating new `Communication` FHIR resources - Real-time message updates using Medplum WebSocket `Subscription` - Auto-update of message status: sent, received, read, directly on `Communication` FHIR resource + - Message deletion - **Media Support** - Image and video attachments @@ -113,6 +114,10 @@ NOTE: Login will not work yet, because Medplum's OAuth2 is not set. See the next EXPO_PUBLIC_MEDPLUM_NATIVE_CLIENT_ID=your_native_client_id ``` +### Configuring Access Policies (for production) + +The app implements message deletion functionality, which requires proper access control in production. You need to set up [Access Policies](https://www.medplum.com/docs/access/access-policies) in Medplum to ensure patients can only read/update/delete their own messages. + ### Testing Run the test suite: From 28a31b486ccfa5a38d1420bf80c92e48516cc931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 4 Feb 2025 11:24:30 -0300 Subject: [PATCH 4/8] LoadingButtonSpinner component --- components/ChatHeader.tsx | 5 +++-- components/ChatMessageBubble.tsx | 7 +++---- components/CreateThreadModal.tsx | 11 +++-------- components/LoadingButtonSpinner.tsx | 17 +++++++++++++++++ 4 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 components/LoadingButtonSpinner.tsx diff --git a/components/ChatHeader.tsx b/components/ChatHeader.tsx index ee4c661..a28bb9b 100644 --- a/components/ChatHeader.tsx +++ b/components/ChatHeader.tsx @@ -6,8 +6,9 @@ import { ChevronLeftIcon, TrashIcon, UserRound, XIcon } from "lucide-react-nativ import { useMemo } from "react"; import { View } from "react-native"; +import { LoadingButtonSpinner } from "@/components/LoadingButtonSpinner"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; -import { Button, ButtonIcon, ButtonSpinner, ButtonText } from "@/components/ui/button"; +import { Button, ButtonIcon, ButtonText } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Pressable } from "@/components/ui/pressable"; import { Text } from "@/components/ui/text"; @@ -118,7 +119,7 @@ export function ChatHeader({ className="mr-2" > {isDeleting ? ( - + ) : ( <> diff --git a/components/ChatMessageBubble.tsx b/components/ChatMessageBubble.tsx index 7bc7fd1..acd07fa 100644 --- a/components/ChatMessageBubble.tsx +++ b/components/ChatMessageBubble.tsx @@ -2,14 +2,14 @@ import { useMedplumProfile } from "@medplum/react-hooks"; import { useVideoPlayer } from "expo-video"; import { VideoView } from "expo-video"; import { CirclePlay, FileDown, UserRound } from "lucide-react-native"; -import { useColorScheme } from "nativewind"; import { memo, useCallback, useRef, useState } from "react"; import { Pressable, StyleSheet, View } from "react-native"; import { Alert } from "react-native"; import { FullscreenImage } from "@/components/FullscreenImage"; +import { LoadingButtonSpinner } from "@/components/LoadingButtonSpinner"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; -import { Button, ButtonIcon, ButtonSpinner, ButtonText } from "@/components/ui/button"; +import { Button, ButtonIcon, ButtonText } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; import type { ChatMessage } from "@/models/chat"; @@ -85,7 +85,6 @@ VideoAttachment.displayName = "VideoAttachment"; function FileAttachment({ attachment }: { attachment: AttachmentWithUrl }) { const [isDownloading, setIsDownloading] = useState(false); - const { colorScheme } = useColorScheme(); const handleShare = useCallback(async () => { setIsDownloading(true); @@ -106,7 +105,7 @@ function FileAttachment({ attachment }: { attachment: AttachmentWithUrl }) { disabled={isDownloading} > {isDownloading ? ( - + ) : ( )} diff --git a/components/CreateThreadModal.tsx b/components/CreateThreadModal.tsx index c67ccc5..031ecc6 100644 --- a/components/CreateThreadModal.tsx +++ b/components/CreateThreadModal.tsx @@ -1,9 +1,9 @@ import { useRouter } from "expo-router"; -import { useColorScheme } from "nativewind"; import { useState } from "react"; +import { LoadingButtonSpinner } from "@/components/LoadingButtonSpinner"; import { Modal, ModalBody, ModalFooter, ModalHeader } from "@/components/Modal"; -import { Button, ButtonSpinner, ButtonText } from "@/components/ui/button"; +import { Button, ButtonText } from "@/components/ui/button"; import { Input, InputField } from "@/components/ui/input"; import { View } from "@/components/ui/view"; @@ -16,7 +16,6 @@ interface CreateThreadModalProps { export function CreateThreadModal({ isOpen, onClose, onCreateThread }: CreateThreadModalProps) { const [topic, setTopic] = useState(""); const [isCreating, setIsCreating] = useState(false); - const { colorScheme } = useColorScheme(); const router = useRouter(); const handleCreate = async () => { @@ -55,11 +54,7 @@ export function CreateThreadModal({ isOpen, onClose, onCreateThread }: CreateThr Cancel diff --git a/components/LoadingButtonSpinner.tsx b/components/LoadingButtonSpinner.tsx new file mode 100644 index 0000000..18084db --- /dev/null +++ b/components/LoadingButtonSpinner.tsx @@ -0,0 +1,17 @@ +import { useColorScheme } from "nativewind"; + +import { ButtonSpinner } from "@/components/ui/button"; + +interface LoadingButtonSpinnerProps { + /** + * Optional override for spinner color. If not provided, will use white for light mode and black for dark mode + */ + color?: string; +} + +export function LoadingButtonSpinner({ color }: LoadingButtonSpinnerProps) { + const { colorScheme } = useColorScheme(); + const spinnerColor = color ?? (colorScheme === "dark" ? "black" : "white"); + + return ; +} From bb72d66c42479e7417d3b1148a7daa518b33cb7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 4 Feb 2025 11:52:40 -0300 Subject: [PATCH 5/8] Refactor ChatHeader --- components/ChatHeader.tsx | 186 ++++++++++++++++++++++++-------------- 1 file changed, 117 insertions(+), 69 deletions(-) diff --git a/components/ChatHeader.tsx b/components/ChatHeader.tsx index a28bb9b..fd2330d 100644 --- a/components/ChatHeader.tsx +++ b/components/ChatHeader.tsx @@ -1,5 +1,4 @@ -import { Patient, Practitioner } from "@medplum/fhirtypes"; -import { Reference } from "@medplum/fhirtypes"; +import { Patient, Practitioner, Reference } from "@medplum/fhirtypes"; import { useMedplumContext } from "@medplum/react-hooks"; import { useRouter } from "expo-router"; import { ChevronLeftIcon, TrashIcon, UserRound, XIcon } from "lucide-react-native"; @@ -14,11 +13,117 @@ import { Pressable } from "@/components/ui/pressable"; import { Text } from "@/components/ui/text"; import { Thread } from "@/models/chat"; -function ChatStatus({ currentThread }: { currentThread: Thread }) { +// Types +interface StatusConfig { + color: string; + message: string; +} + +interface ChatStatusProps { + currentThread: Thread; +} + +interface ThreadInfoProps { + currentThread: Thread; + avatarURL: string | null | undefined; +} + +interface SelectionInfoProps { + selectedCount: number; + onDelete?: () => void; + isDeleting?: boolean; +} + +interface BackButtonProps { + isSelectionMode: boolean; + onCancelSelection?: () => void; +} + +export interface ChatHeaderProps { + currentThread: Thread; + getAvatarURL: ( + reference: Reference | undefined, + ) => string | null | undefined; + selectedCount?: number; + onDelete?: () => void; + onCancelSelection?: () => void; + isDeleting?: boolean; +} + +// Helper Components +function BackButton({ isSelectionMode, onCancelSelection }: BackButtonProps) { + const router = useRouter(); + + const handlePress = () => { + if (isSelectionMode) { + onCancelSelection?.(); + } else if (router.canGoBack()) { + router.back(); + } else { + router.replace("/"); + } + }; + + return ( + + + + ); +} + +function SelectionInfo({ selectedCount, onDelete, isDeleting = false }: SelectionInfoProps) { + return ( + + + {selectedCount} selected + + + + ); +} + +function ThreadInfo({ currentThread, avatarURL }: ThreadInfoProps) { + return ( + + + + {avatarURL && } + + + + {currentThread.topic} + + + + + ); +} + +function ChatStatus({ currentThread }: ChatStatusProps) { const { profile } = useMedplumContext(); const isPatient = profile?.resourceType === "Patient"; - const { color, message } = useMemo(() => { + const { color, message }: StatusConfig = useMemo(() => { if (!isPatient && !currentThread.lastMessageSentAt) { return { color: "bg-warning-500", @@ -59,17 +164,7 @@ function ChatStatus({ currentThread }: { currentThread: Thread }) { ); } -export interface ChatHeaderProps { - currentThread: Thread; - getAvatarURL: ( - reference: Reference | undefined, - ) => string | null | undefined; - selectedCount?: number; - onDelete?: () => void; - onCancelSelection?: () => void; - isDeleting?: boolean; -} - +// Main Component export function ChatHeader({ currentThread, getAvatarURL, @@ -78,69 +173,22 @@ export function ChatHeader({ onCancelSelection, isDeleting = false, }: ChatHeaderProps) { - const router = useRouter(); const { profile } = useMedplumContext(); const avatarURL = getAvatarURL(currentThread.getAvatarRef({ profile })); - const isSelectionMode = selectedCount > 0; return ( - { - if (isSelectionMode) { - onCancelSelection?.(); - } else if (router.canGoBack()) { - router.back(); - } else { - router.replace("/"); - } - }} - > - - + {isSelectionMode ? ( - - - {selectedCount} selected - - - + ) : ( - - - - {avatarURL && } - - - - {currentThread.topic} - - - - + )} From c19a9ea481ce25cc871cc89f8c2ac86581d5892d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 4 Feb 2025 12:02:05 -0300 Subject: [PATCH 6/8] Simplify view nesting --- components/ChatMessageBubble.tsx | 78 +++++++++++++++----------------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/components/ChatMessageBubble.tsx b/components/ChatMessageBubble.tsx index acd07fa..6b45d88 100644 --- a/components/ChatMessageBubble.tsx +++ b/components/ChatMessageBubble.tsx @@ -147,52 +147,48 @@ export function ChatMessageBubble({ }, [message.id, onSelect, selectionEnabled]); return ( - + {/* Selection background */} {selected && } - - - - - - {avatarURL && } - - - {message.attachment?.url && ( - - {hasImage ? ( - - ) : hasVideo ? ( - - ) : ( - - )} - + + + + {avatarURL && } + + + {message.attachment?.url && ( + + {hasImage ? ( + + ) : hasVideo ? ( + + ) : ( + )} - {Boolean(message.text) && {message.text}} - {formatTime(message.sentAt)} - + )} + {Boolean(message.text) && {message.text}} + {formatTime(message.sentAt)} - - + + ); } From d506dda60a612fca281dd70704fd1948edfeb03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 4 Feb 2025 12:07:45 -0300 Subject: [PATCH 7/8] Fix modal border --- components/Modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Modal.tsx b/components/Modal.tsx index ba92fd1..b55cd26 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -56,7 +56,7 @@ export function Modal({ isOpen, onClose, children }: ModalProps) { style={{ paddingTop: insets.top }} > - + {children} From 6e31e153d205e3e2b343cfb27fac71074392b9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fl=C3=A1vio=20Juvenal?= Date: Tue, 4 Feb 2025 12:10:36 -0300 Subject: [PATCH 8/8] Remove unnecessary View and Box components --- components/CreateThreadModal.tsx | 19 ++++---- components/ThreadList.tsx | 75 +++++++++++++++----------------- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/components/CreateThreadModal.tsx b/components/CreateThreadModal.tsx index 031ecc6..9b35544 100644 --- a/components/CreateThreadModal.tsx +++ b/components/CreateThreadModal.tsx @@ -5,7 +5,6 @@ import { LoadingButtonSpinner } from "@/components/LoadingButtonSpinner"; import { Modal, ModalBody, ModalFooter, ModalHeader } from "@/components/Modal"; import { Button, ButtonText } from "@/components/ui/button"; import { Input, InputField } from "@/components/ui/input"; -import { View } from "@/components/ui/view"; interface CreateThreadModalProps { isOpen: boolean; @@ -38,16 +37,14 @@ export function CreateThreadModal({ isOpen, onClose, onCreateThread }: CreateThr Create New Thread - - - - - + + +