Skip to content

Commit

Permalink
Merge pull request #11 from vintasoftware/feat/read-unread-status
Browse files Browse the repository at this point in the history
Message status (sent, received, read)
  • Loading branch information
fjsj authored Dec 30, 2024
2 parents 95df367 + 7bb52c7 commit b01ebdc
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 41 deletions.
174 changes: 170 additions & 4 deletions __tests__/hooks/headless/useChatMessages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const mockThread: Communication = {
const mockMessage1: Communication = {
resourceType: "Communication",
id: "msg-1",
status: "completed",
status: "in-progress",
sent: "2024-01-01T12:01:00Z",
sender: createReference(mockPatient),
payload: [{ contentString: "Hello" }],
Expand All @@ -39,7 +39,7 @@ const mockMessage1: Communication = {
const mockMessage2: Communication = {
resourceType: "Communication",
id: "msg-2",
status: "completed",
status: "in-progress",
sent: "2024-01-01T12:02:00Z",
sender: createReference(mockPractitioner),
payload: [{ contentString: "Hi there" }],
Expand Down Expand Up @@ -147,7 +147,7 @@ describe("useChatMessages", () => {
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
resourceType: "Communication",
status: "completed",
status: "in-progress",
sender: {
reference: "Patient/test-patient",
display: "John Doe",
Expand Down Expand Up @@ -246,7 +246,7 @@ describe("useChatMessages", () => {

// Verify the onMessageReceived callback was called
expect(onMessageReceivedMock).toHaveBeenCalledTimes(1);
expect(onMessageReceivedMock).toHaveBeenCalledWith(newMessage);
expect(onMessageReceivedMock).toHaveBeenCalledWith(expect.objectContaining(newMessage));
});

test("Ignores outgoing new message on subscription", async () => {
Expand Down Expand Up @@ -326,6 +326,10 @@ describe("useChatMessages", () => {
expect(result.current.messages[1].text).toBe("Updated message");
expect(result.current.messages).toHaveLength(2);
});

// Verify the onMessageUpdated callback was called
expect(onMessageUpdatedMock).toHaveBeenCalledTimes(1);
expect(onMessageUpdatedMock).toHaveBeenCalledWith(expect.objectContaining(updatedMessage));
});

test("Messages cleared if profile changes", async () => {
Expand Down Expand Up @@ -509,4 +513,166 @@ describe("useChatMessages", () => {
// Verify no messages were loaded
expect(result.current.messages).toHaveLength(0);
});

test("New message starts with sent status only", async () => {
const { medplum } = await setup();
const { result } = renderHook(() => useChatMessages({ threadId: "test-thread" }), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
});

// Send a new message
act(() => {
result.current.setMessage("New message");
});

await act(async () => {
await result.current.sendMessage();
});

// Wait for messages to update
await waitFor(() => {
const lastMessage = result.current.messages[result.current.messages.length - 1];
expect(lastMessage.sentAt).toBeDefined();
expect(lastMessage.received).toBeUndefined();
expect(lastMessage.read).toBe(false);
});
});

test("Received status is set when messages are rendered first time", async () => {
const { medplum } = await setup();

// Create a new message from practitioner without received status
const newMessage: Communication = {
resourceType: "Communication",
id: "msg-3",
status: "in-progress",
sent: new Date().toISOString(),
sender: createReference(mockPractitioner),
payload: [{ contentString: "Test initial received status" }],
partOf: [createReference(mockThread)],
};
await medplum.createResource(newMessage);

const { result } = renderHook(() => useChatMessages({ threadId: "test-thread" }), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});

// Verify the message in the list has received status
await waitFor(() => {
const message = result.current.messages.find((m) => m.id === "msg-3");
expect(message?.received).toBeDefined();
expect(message?.read).toBe(false);
});
});

test("Received status is set when message is received by other user", async () => {
const { medplum, subManager } = await setup();
const { result } = renderHook(() => useChatMessages({ threadId: "test-thread" }), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});

// Create a new incoming message without received status
const newMessage: Communication = {
resourceType: "Communication",
id: "msg-3",
status: "in-progress",
sent: new Date().toISOString(),
sender: createReference(mockPractitioner),
payload: [{ contentString: "Test received status" }],
partOf: [createReference(mockThread)],
};
await medplum.createResource(newMessage);

// Create and emit the subscription bundle
const bundle = await createCommunicationSubBundle(newMessage);
act(() => {
subManager.emitEventForCriteria(`Communication?part-of=Communication/test-thread`, {
type: "message",
payload: bundle,
});
});

// Verify received timestamp is set
await waitFor(() => {
const lastMessage = result.current.messages[result.current.messages.length - 1];
expect(lastMessage.text).toBe("Test received status");
expect(lastMessage.received).toBeDefined();
expect(lastMessage.read).toBe(false);
});
});

test("Read status is set when markMessageAsRead is called", async () => {
const { medplum } = await setup();
const { result } = renderHook(() => useChatMessages({ threadId: "test-thread" }), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});

// Get an unread message
const unreadMessage = result.current.messages.find((m) => !m.read);
expect(unreadMessage).toBeDefined();

// Mark message as read
await act(async () => {
await result.current.markMessageAsRead(unreadMessage!.id);
});

// Verify message is marked as read
await waitFor(() => {
const message = result.current.messages.find((m) => m.id === unreadMessage!.id);
expect(message?.read).toBe(true);
});
});

test("markMessageAsRead does nothing if message is already read", async () => {
const { medplum } = await setup();

// Create a new message from practitioner with read status (completed)
const newMessage: Communication = {
resourceType: "Communication",
id: "msg-3",
status: "completed",
sent: new Date().toISOString(),
sender: createReference(mockPractitioner),
payload: [{ contentString: "Test received status" }],
partOf: [createReference(mockThread)],
};
await medplum.createResource(newMessage);

// Render the hook
const { result } = renderHook(() => useChatMessages({ threadId: "test-thread" }), {
wrapper: ({ children }) => <MedplumProvider medplum={medplum}>{children}</MedplumProvider>,
});

// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
const message = result.current.messages.find((m) => m.id === newMessage.id);
expect(message?.read).toBe(true);
});

// Mark message as read
await act(async () => {
await result.current.markMessageAsRead(newMessage.id!);
});
// Verify message is still marked as read
await waitFor(() => {
const message = result.current.messages.find((m) => m.id === newMessage.id);
expect(message?.read).toBe(true);
});
});
});
16 changes: 15 additions & 1 deletion app/(app)/thread/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useMedplum } from "@medplum/react-hooks";
import { useLocalSearchParams } from "expo-router";
import { useEffect } from "react";
import { SafeAreaView } from "react-native-safe-area-context";

import { ChatHeader } from "@/components/ChatHeader";
Expand All @@ -8,8 +10,20 @@ import { Spinner } from "@/components/ui/spinner";
import { useChatMessages } from "@/hooks/headless/useChatMessages";

export default function ThreadPage() {
const medplum = useMedplum();
const profile = medplum.getProfile();
const { id } = useLocalSearchParams<{ id: string }>();
const { message, setMessage, messages, loading, sendMessage } = useChatMessages({ threadId: id });
const { message, setMessage, messages, loading, sendMessage, markMessageAsRead } =
useChatMessages({ threadId: id });

// Mark all unread messages from others as read
useEffect(() => {
messages.forEach((message) => {
if (!message.read && message.senderType !== profile?.resourceType) {
markMessageAsRead(message.id);
}
});
}, [messages, profile, markMessageAsRead]);

if (loading) {
return (
Expand Down
9 changes: 8 additions & 1 deletion components/ThreadList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ function ThreadItem({
</Avatar>

<View className="flex-1">
<Text className="text-base font-medium text-typography-900">{thread.topic}</Text>
<View className="flex-row items-center gap-2">
<Text className="text-base font-medium text-typography-900">{thread.topic}</Text>
{thread.unreadCount > 0 && (
<View className="rounded-full bg-primary-500 px-2 py-0.5">
<Text className="text-xs text-typography-0">{thread.unreadCount}</Text>
</View>
)}
</View>
<Text className="text-sm text-typography-600" numberOfLines={1}>
{thread.lastMessage}
</Text>
Expand Down
78 changes: 61 additions & 17 deletions hooks/headless/useChatMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,44 @@ export function useBaseChatCommunications(props: BaseChatProps) {
[profile, medplum],
);

const updateUnreceivedMessages = useCallback(
async (comms: Communication[]): Promise<Communication[]> => {
const newComms = comms.filter((comm) => !comm.received);
if (newComms.length === 0) return comms;

const now = new Date().toISOString();
const updatedComms = await Promise.all(
newComms.map((comm) =>
medplum.patchResource("Communication", comm.id!, [
{ op: "add", path: "/received", value: now },
]),
),
);

// Replace the original comms with updated ones
return comms.map((msg) => updatedComms.find((updated) => updated.id === msg.id) || msg);
},
[medplum],
);

const fetchMessages = useCallback(async (): Promise<void> => {
try {
setLoading(true);
// Fetch messages
const searchParams = new URLSearchParams(query);
searchParams.append("_sort", "-sent");
const searchResult = await medplum.searchResources("Communication", searchParams, {
let searchResult = (await medplum.searchResources("Communication", searchParams, {
cache: "no-cache",
});
})) as Communication[];
// Update all messages without received timestamp
searchResult = await updateUnreceivedMessages(searchResult);
setAndSortCommunications(communicationsRef.current, searchResult, setCommunications);
} catch (err) {
onError?.(err as Error);
} finally {
setLoading(false);
}
}, [query, medplum, setCommunications, onError]);
}, [query, medplum, updateUnreceivedMessages, setCommunications, onError]);

// Load messages on mount
useEffect(() => {
Expand All @@ -93,20 +116,25 @@ export function useBaseChatCommunications(props: BaseChatProps) {
// Subscribe to new messages
useSubscription(
`Communication?${query}`,
(bundle: Bundle) => {
const communication = bundle.entry?.[1]?.resource as Communication;
setAndSortCommunications(communicationsRef.current, [communication], setCommunications);
async (bundle: Bundle) => {
let communication = bundle.entry?.[1]?.resource as Communication;
// If we are the sender of this message, then we want to skip calling `onMessageUpdated` or `onMessageReceived`
if (getReferenceString(communication.sender as Reference) === profileRefStr) {
return;
}
// If this communication already exists, call `onMessageUpdated`
if (communicationsRef.current.find((c) => c.id === communication.id)) {
onMessageUpdated?.(communication);
} else {
// Else a new message was created
// Call `onMessageReceived` when we are not the sender of a chat message that came in
onMessageReceived?.(communication);
if (getReferenceString(communication.sender as Reference) !== profileRefStr) {
// If this communication already exists, call `onMessageUpdated`
if (communicationsRef.current.find((c) => c.id === communication.id)) {
onMessageUpdated?.(communication);
} else {
// Else a new message was created
// Update the communication with received timestamp
if (!communication.received) {
communication = await medplum.patchResource("Communication", communication.id!, [
{ op: "add", path: "/received", value: new Date().toISOString() },
]);
}
// Call `onMessageReceived` when we are not the sender of a chat message that came in
onMessageReceived?.(communication);
}
setAndSortCommunications(communicationsRef.current, [communication], setCommunications);
}
},
{
Expand Down Expand Up @@ -170,6 +198,8 @@ export function communicationToMessage(communication: Communication): ChatMessag
: "Practitioner") as "Patient" | "Practitioner",
sentAt: new Date(communication.sent as string),
messageOrder: getMessageOrder(communication),
received: communication.received ? new Date(communication.received) : undefined,
read: communication.status === "completed",
};
}

Expand Down Expand Up @@ -206,7 +236,7 @@ export function useChatMessages(props: ChatMessagesProps) {

const newCommunication = await medplum.createResource({
resourceType: "Communication",
status: "completed",
status: "in-progress",
sent: new Date().toISOString(),
sender: createReference(profile),
payload: [
Expand All @@ -225,6 +255,19 @@ export function useChatMessages(props: ChatMessagesProps) {
setMessage("");
}, [message, profile, medplum, threadId, communications, setCommunications]);

const markMessageAsRead = useCallback(
async (messageId: string) => {
const message = communications.find((c) => c.id === messageId);
if (!message || message.status === "completed") return;

const updatedMessage = await medplum.patchResource("Communication", messageId, [
{ op: "add", path: "/status", value: "completed" },
]);
setAndSortCommunications(communications, [updatedMessage], setCommunications);
},
[communications, medplum, setCommunications],
);

return {
message,
setMessage,
Expand All @@ -233,5 +276,6 @@ export function useChatMessages(props: ChatMessagesProps) {
connectedOnce,
reconnecting,
sendMessage,
markMessageAsRead,
};
}
Loading

0 comments on commit b01ebdc

Please sign in to comment.