• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

medplum / medplum / 30566672722

30 Jul 2026 05:35PM UTC coverage: 92.022% (+0.03%) from 91.994%
30566672722

push

github

web-flow
Provider: Add Message Settings to Messages (#9621)

* feat(react): rework New Message dialog with MultiResourceInput

Replace the QuestionnaireForm-based practitioner picker with
MultiResourceInput, defaulting the signed-in provider. Make the patient
required and disable Next until a patient and at least one practitioner are
selected. Apply the shared Send-Fax-style modal spacing/font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kevin Shaw <kevin@medplum.com>

* feat(react): add Message Settings dialog for threads

Add EditTopicDialog, opened from a new info-icon button in the thread header,
to edit a thread's topic and practitioner participants after creation. References
are resolved in parallel before render so all fields appear together, with a
failed patient read handled independently. Threads with no practitioner recipient
fall back to showing the sender practitioner. Non-practitioner recipients are
preserved on save.

Add useThreadInbox.updateThreadParent so an edited thread updates in place rather
than refetching, keeping a draft thread (no reply yet) in the list. Order the
inbox by latest message time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kevin Shaw <kevin@medplum.com>

* feat(react): auto-select first thread in inbox

When no thread is selected and the list has items, navigate to the first
thread, matching the Tasks/Faxes boards. Add an optional replace flag to the
Medplum navigate function (forwarded by the app router wrappers) so the implicit
selection replaces history instead of adding a back-button entry that would
re-trigger the selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Kevin Shaw <kevin@medplum.com>

* fix(react): type resolved practitioners via discriminant narrowing

The full tsc build (not run by vitest/esbuild) rejected the explicit
`PromiseFulfilledResult<Practitioner>` type predicate because readReference
resolves to WithId<Prac... (continued)

22738 of 25819 branches covered (88.07%)

Branch coverage included in aggregate %.

103 of 113 new or added lines in 10 files covered. (91.15%)

1 existing line in 1 file now uncovered.

40309 of 42694 relevant lines covered (94.41%)

12266.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.0
/packages/react/src/chat/ThreadChat/ThreadChat.tsx
1
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
2
// SPDX-License-Identifier: Apache-2.0
3
import { createReference, formatCodeableConcept, getReferenceString } from '@medplum/core';
4
import type { Communication, CommunicationPayload, DocumentReference, Reference } from '@medplum/fhirtypes';
5
import { useMedplum, useMedplumProfile, usePrevious } from '@medplum/react-hooks';
6
import type { JSX } from 'react';
7
import { useCallback, useEffect, useMemo, useState } from 'react';
8
import { BaseChat } from '../BaseChat/BaseChat';
9

10
export interface ThreadChatProps {
11
  readonly thread: Communication;
12
  readonly title?: string;
13
  readonly onMessageSent?: (message: Communication) => void;
14
  readonly inputDisabled?: boolean;
15
  readonly excludeHeader?: boolean;
16
  readonly uploadEnabled?: boolean;
17
  readonly onError?: (err: Error) => void;
18
  readonly onViewInDocuments?: (reference: Reference<DocumentReference>) => void;
19
}
20

21
export function ThreadChat(props: ThreadChatProps): JSX.Element | null {
22
  const { thread, title, onMessageSent, inputDisabled, excludeHeader, uploadEnabled, onError, onViewInDocuments } =
23
    props;
97✔
24
  const medplum = useMedplum();
97✔
25
  const profile = useMedplumProfile();
97✔
26
  const prevThreadId = usePrevious(thread?.id);
97✔
27
  const [communications, setCommunications] = useState<Communication[]>([]);
97✔
28

29
  const profileRef = useMemo(() => (profile ? createReference(profile) : undefined), [profile]);
97✔
30
  const threadRef = useMemo(() => createReference(thread), [thread]);
97✔
31

32
  useEffect(() => {
97✔
33
    if (thread?.id !== prevThreadId) {
56✔
34
      setCommunications([]);
28✔
35
    }
36
  }, [thread?.id, prevThreadId]);
37

38
  const sendMessage = useCallback(
97✔
39
    (message: string, file?: File, existingDocRef?: DocumentReference) => {
40
      const profileRefStr = profileRef ? getReferenceString(profileRef) : undefined;
5!
41
      if (!profileRefStr) {
5!
42
        return;
×
43
      }
44

45
      const buildAndSend = async (): Promise<void> => {
5✔
46
        const payload: CommunicationPayload[] = [];
5✔
47
        if (message) {
5✔
48
          payload.push({ contentString: message });
3✔
49
        }
50
        if (existingDocRef) {
5✔
51
          payload.push({ contentReference: createReference(existingDocRef) });
1✔
52
        } else if (file) {
4✔
53
          const docRef = await medplum.createDocumentReference({
2✔
54
            data: file,
55
            contentType: file.type || 'application/octet-stream',
2!
56
            filename: file.name,
57
            additionalFields: {
58
              ...(thread.subject ? { subject: thread.subject } : {}),
2✔
59
              description: file.name,
60
            },
61
          });
62
          payload.push({ contentReference: createReference(docRef) });
2✔
63
        }
64
        const communication = await medplum.createResource<Communication>({
5✔
65
          resourceType: 'Communication',
66
          status: 'in-progress',
67
          sender: profileRef,
68
          recipient: thread.recipient?.filter((ref) => getReferenceString(ref) !== profileRefStr) ?? [],
10!
69
          sent: new Date().toISOString(),
70
          payload,
71
          partOf: [threadRef],
72
          subject: thread.subject,
73
        });
74
        setCommunications([...communications, communication]);
5✔
75
        // Touch the thread header so its meta.lastUpdated tracks message activity
76
        if (thread.id) {
5!
77
          try {
5✔
78
            await medplum.patchResource('Communication', thread.id, [
5✔
79
              { op: 'add', path: '/sent', value: communication.sent },
80
            ]);
81
          } catch (err) {
NEW
82
            onError?.(err as Error);
×
83
          }
84
        }
85
        onMessageSent?.(communication);
5✔
86
      };
87

88
      buildAndSend().catch(console.error);
5✔
89
    },
90
    [medplum, profileRef, thread, threadRef, communications, onMessageSent, onError]
91
  );
92

93
  // Currently we only support `delivered` on chats with 2 participants
94
  // Normally we would use `useCallback` to memoize a function
95
  // But in this case we only want to conditionally pass a function if the thread has 2 participants...
96
  // If the thread has 3 or more participants, we do not pass this function; instead we pass undefined
97
  const onMessageReceived = useMemo(
97✔
98
    () =>
99
      thread.recipient?.length === 2
28✔
100
        ? (message: Communication): void => {
101
            if (!(message.received && message.status === 'completed')) {
3!
102
              medplum
3✔
103
                .updateResource({
104
                  ...message,
105
                  received: message.received ?? new Date().toISOString(), // Mark as received if needed
6✔
106
                  status: 'completed', // Mark as 'read'
107
                  // See: https://www.medplum.com/docs/communications/messaging-data-model#communication-lifecycle
108
                  // for more info about recommended `Communication` lifecycle
109
                })
110
                .catch(console.error);
111
            }
112
          }
113
        : undefined,
114
    [medplum, thread.recipient?.length]
115
  );
116

117
  if (!profile) {
97✔
118
    return null;
2✔
119
  }
120

121
  return (
95✔
122
    <BaseChat
123
      title={title ?? (thread?.topic ? formatCodeableConcept(thread.topic) : '[No thread title]')}
153✔
124
      communications={communications}
125
      setCommunications={setCommunications}
126
      query={`part-of=Communication/${thread.id as string}`}
127
      sendMessage={sendMessage}
128
      onMessageReceived={onMessageReceived}
129
      inputDisabled={inputDisabled}
130
      excludeHeader={excludeHeader}
131
      uploadEnabled={uploadEnabled}
132
      onError={onError}
133
      attachmentSubjectRef={thread.subject}
134
      onViewInDocuments={onViewInDocuments}
135
    />
136
  );
137
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc