Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Message deletion #19

Merged
merged 8 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions __tests__/hooks/useSingleThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
75 changes: 70 additions & 5 deletions app/(app)/thread/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<Set<string>>(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(() => {
Expand Down Expand Up @@ -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 <LoadingScreen />;
}

return (
<View className="flex-1 bg-background-50">
<ChatHeader currentThread={thread} getAvatarURL={getAvatarURL} />
<ChatMessageList messages={thread.messages} loading={isSending || isLoading} />
<ChatHeader
currentThread={thread}
getAvatarURL={getAvatarURL}
selectedCount={selectedMessages.size}
onDelete={handleDeleteMessages}
onCancelSelection={handleCancelSelection}
isDeleting={isDeleting}
/>
<ChatMessageList
messages={thread.messages}
loading={isSending || isLoading}
selectedMessages={selectedMessages}
onMessageSelect={handleMessageSelect}
selectionEnabled={selectedMessages.size > 0}
/>
<ChatMessageInput
message={message}
setMessage={setMessage}
onAttachment={handleAttachment}
onSend={handleSendMessage}
isSending={isSending || isAttaching}
disabled={selectedMessages.size > 0}
/>
<MessageDeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleConfirmDelete}
selectedCount={selectedMessages.size}
isDeleting={isDeleting}
/>
</View>
);
Expand Down
Loading