Skip to main content
Glama
ThreadInbox.tsx10.6 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { Flex, Paper, ScrollArea, Stack, Text, ActionIcon, Divider, Button, Center, ThemeIcon, Menu, Skeleton, Box, Pagination, } from '@mantine/core'; import type { Communication, Patient, Reference } from '@medplum/fhirtypes'; import { PatientSummary, ThreadChat } from '@medplum/react'; import { useState, useEffect, useMemo } from 'react'; import type { JSX } from 'react'; import { IconMessageCircle, IconChevronDown, IconPlus } from '@tabler/icons-react'; import { getReferenceString } from '@medplum/core'; import { ChatList } from './ChatList'; import { NewTopicDialog } from './NewTopicDialog'; import { useThreadInbox } from '../../hooks/useThreadInbox'; import classes from './ThreadInbox.module.css'; import { useDisclosure } from '@mantine/hooks'; import { showErrorNotification } from '../../utils/notifications'; import cx from 'clsx'; /** * ThreadInbox is a component that displays a list of threads and allows the user to select a thread to view. * @param query - The query to fetch all communications. * @param threadId - The id of the thread to select. * @param subject - The default subject when creating a new thread. * @param showPatientSummary - Whether to show the patient summary. * @param handleNewThread - A function to handle a new thread. * @param onSelectedItem - A function to handle the selected item. */ interface ThreadInboxProps { query: string; threadId: string | undefined; subject?: Reference<Patient> | Patient | undefined; showPatientSummary?: boolean | undefined; handleNewThread: (message: Communication) => void; onSelectedItem: (topic: Communication) => string; } export function ThreadInbox(props: ThreadInboxProps): JSX.Element { const { query, threadId, subject, showPatientSummary = false, handleNewThread, onSelectedItem } = props; const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const [status, setStatus] = useState<Communication['status']>('in-progress'); const [currentPage, setCurrentPage] = useState<number>(1); const itemsPerPage = 20; const queryWithStatus = useMemo(() => `${query}&status=${status}`, [query, status]); const offset = (currentPage - 1) * itemsPerPage; const { loading, error, threadMessages, selectedThread, total, handleThreadStatusChange, addThreadMessage } = useThreadInbox({ query: queryWithStatus, threadId, offset, count: itemsPerPage, }); useEffect(() => { if (error) { showErrorNotification(error); } }, [error]); const handleStatusChange = (newStatus: Communication['status']): void => { setStatus(newStatus); setCurrentPage(1); }; const handleTopicStatusChangeWithErrorHandling = async (newStatus: Communication['status']): Promise<void> => { try { await handleThreadStatusChange(newStatus); } catch (error) { showErrorNotification(error); } }; const handleNewTopicCompletion = (message: Communication): void => { addThreadMessage(message); handleNewThread(message); }; return ( <> <div className={classes.container}> <Flex direction="row" h="100%" w="100%"> {/* Left sidebar - Messages list */} <Flex direction="column" w={300} h="100%" className={classes.rightBorder}> <Paper h="100%" style={{ display: 'flex', flexDirection: 'column' }}> <ScrollArea style={{ flex: 1 }} scrollbarSize={10} type="hover" scrollHideDelay={250}> <Flex h={64} align="center" justify="space-between" p="md"> <Text fz="h4" fw={800} truncate> Messages </Text> <ActionIcon radius="50%" variant="filled" color="blue" onClick={openModal}> <IconPlus size={16} /> </ActionIcon> </Flex> <Divider /> <Flex p="md" gap="xs"> <Button className={cx(classes.button, { [classes.selected]: status === 'in-progress' })} h={32} radius="xl" onClick={() => handleStatusChange('in-progress')} > In progress </Button> <Button className={cx(classes.button, { [classes.selected]: status === 'completed' })} h={32} radius="xl" onClick={() => handleStatusChange('completed')} > Completed </Button> </Flex> <Divider /> {loading ? ( <Stack gap="md" p="md"> {Array.from({ length: 10 }).map((_, index) => ( <Flex key={index} gap="sm" align="flex-start"> <Skeleton height={40} width={40} radius="50%" /> <Box style={{ flex: 1 }}> <Flex direction="column" gap="xs"> <Skeleton height={16} width={`${Math.random() * 40 + 60}%`} /> <Skeleton height={14} width={`${Math.random() * 50 + 40}%`} /> </Flex> </Box> </Flex> ))} </Stack> ) : ( threadMessages.length > 0 && ( <ChatList threads={threadMessages} selectedCommunication={selectedThread} onSelectedItem={onSelectedItem} /> ) )} {threadMessages.length === 0 && !loading && <EmptyMessagesState />} </ScrollArea> {!loading && total !== undefined && total > itemsPerPage && ( <Box p="md"> <Center> <Pagination value={currentPage} total={Math.ceil(total / itemsPerPage)} onChange={setCurrentPage} size="sm" siblings={1} boundaries={1} /> </Center> </Box> )} </Paper> </Flex> {selectedThread ? ( <> {/* Main chat area */} <Flex direction="column" style={{ flex: 1 }} h="100%" className={classes.rightBorder}> <Paper h="100%"> <Stack h="100%" gap={0}> <Flex h={64} align="center" justify="space-between" p="md"> <Text fw={800} truncate fz="lg"> {selectedThread.topic?.text ?? 'Messages'} </Text> <Menu position="bottom-end" shadow="md"> <Menu.Target> <Button variant="light" color={getStatusColor(selectedThread.status)} rightSection={ selectedThread.status === 'completed' ? undefined : <IconChevronDown size={16} /> } radius="xl" size="sm" > {selectedThread.status .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ')} </Button> </Menu.Target> {selectedThread.status !== 'completed' && ( <> <Menu.Dropdown> <Menu.Item onClick={() => handleTopicStatusChangeWithErrorHandling('completed')}> Completed </Menu.Item> </Menu.Dropdown> </> )} </Menu> </Flex> <Divider /> <Flex direction="column" style={{ flex: 1 }} h="100%"> <ThreadChat key={`${getReferenceString(selectedThread)}`} title={'Messages'} thread={selectedThread} excludeHeader={true} /> </Flex> </Stack> </Paper> </Flex> {/* Right sidebar - Patient summary */} {selectedThread.subject && showPatientSummary && ( <Flex direction="column" w={300} h="100%"> <ScrollArea p={0} h="100%" scrollbarSize={10} type="hover" scrollHideDelay={250}> <PatientSummary key={selectedThread.id} patient={selectedThread.subject as Reference<Patient>} /> </ScrollArea> </Flex> )} </> ) : ( <Flex direction="column" style={{ flex: 1 }} h="100%"> <NoMessages /> </Flex> )} </Flex> </div> <NewTopicDialog subject={subject} opened={modalOpened} onClose={closeModal} onSubmit={handleNewTopicCompletion} /> </> ); } function NoMessages(): JSX.Element { return ( <Center h="100%" w="100%"> <Stack align="center" gap="md"> <ThemeIcon size={64} variant="light" color="gray"> <IconMessageCircle size={32} /> </ThemeIcon> <Stack align="center" gap="xs"> <Text size="sm" c="dimmed" ta="center"> Select a message from the list to view details </Text> </Stack> </Stack> </Center> ); } function getStatusColor(status: Communication['status']): string { if (status === 'completed') { return 'green'; } if (status === 'stopped') { return 'red'; } return 'blue'; } function EmptyMessagesState(): JSX.Element { return ( <Flex direction="column" h="100%" justify="center" align="center"> <Stack align="center" gap="md" pt="xl"> <IconMessageCircle size={64} color="var(--mantine-color-gray-4)" /> <Text size="lg" c="dimmed" fw={500}> No messages found </Text> </Stack> </Flex> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server