build ok,
This commit is contained in:
224
002_source/cms/src/components/dashboard/chat/chat-context.tsx
Normal file
224
002_source/cms/src/components/dashboard/chat/chat-context.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { Contact, Message, MessageType, Participant, Thread } from './types';
|
||||
|
||||
function noop(): void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type CreateThreadParams = { type: 'direct'; recipientId: string } | { type: 'group'; recipientIds: string[] };
|
||||
|
||||
export interface CreateMessageParams {
|
||||
threadId: string;
|
||||
type: MessageType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatContextValue {
|
||||
contacts: Contact[];
|
||||
threads: Thread[];
|
||||
messages: Map<string, Message[]>;
|
||||
createThread: (params: CreateThreadParams) => string;
|
||||
markAsRead: (threadId: string) => void;
|
||||
createMessage: (params: CreateMessageParams) => void;
|
||||
openDesktopSidebar: boolean;
|
||||
setOpenDesktopSidebar: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openMobileSidebar: boolean;
|
||||
setOpenMobileSidebar: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const ChatContext = React.createContext<ChatContextValue>({
|
||||
contacts: [],
|
||||
threads: [],
|
||||
messages: new Map(),
|
||||
createThread: noop as () => string,
|
||||
markAsRead: noop,
|
||||
createMessage: noop,
|
||||
openDesktopSidebar: true,
|
||||
setOpenDesktopSidebar: noop,
|
||||
openMobileSidebar: true,
|
||||
setOpenMobileSidebar: noop,
|
||||
});
|
||||
|
||||
export interface ChatProviderProps {
|
||||
children: React.ReactNode;
|
||||
contacts: Contact[];
|
||||
threads: Thread[];
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
contacts: initialContacts = [],
|
||||
threads: initialLabels = [],
|
||||
messages: initialMessages = [],
|
||||
}: ChatProviderProps): React.JSX.Element {
|
||||
const [contacts, setContacts] = React.useState<Contact[]>([]);
|
||||
const [threads, setThreads] = React.useState<Thread[]>([]);
|
||||
const [messages, setMessages] = React.useState<Map<string, Message[]>>(new Map());
|
||||
const [openDesktopSidebar, setOpenDesktopSidebar] = React.useState<boolean>(true);
|
||||
const [openMobileSidebar, setOpenMobileSidebar] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setContacts(initialContacts);
|
||||
}, [initialContacts]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setThreads(initialLabels);
|
||||
}, [initialLabels]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setMessages(
|
||||
initialMessages.reduce((acc, curr) => {
|
||||
const byThread = acc.get(curr.threadId) ?? [];
|
||||
// We unshift the message to ensure the messages are sorted by date
|
||||
byThread.unshift(curr);
|
||||
acc.set(curr.threadId, byThread);
|
||||
return acc;
|
||||
}, new Map<string, Message[]>())
|
||||
);
|
||||
}, [initialMessages]);
|
||||
|
||||
const handleCreateThread = React.useCallback(
|
||||
(params: CreateThreadParams): string => {
|
||||
// Authenticated user
|
||||
const userId = 'USR-000';
|
||||
|
||||
// Check if the thread already exists
|
||||
let thread = threads.find((thread) => {
|
||||
if (params.type === 'direct') {
|
||||
if (thread.type !== 'direct') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return thread.participants
|
||||
.filter((participant) => participant.id !== userId)
|
||||
.find((participant) => participant.id === params.recipientId);
|
||||
}
|
||||
|
||||
if (thread.type !== 'group') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recipientIds = thread.participants
|
||||
.filter((participant) => participant.id !== userId)
|
||||
.map((participant) => participant.id);
|
||||
|
||||
if (params.recipientIds.length !== recipientIds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return params.recipientIds.every((recipientId) => recipientIds.includes(recipientId));
|
||||
});
|
||||
|
||||
if (thread) {
|
||||
return thread.id;
|
||||
}
|
||||
|
||||
// Create a new thread
|
||||
|
||||
const participants: Participant[] = [{ id: 'USR-000', name: 'Sofia Rivers', avatar: '/assets/avatar.png' }];
|
||||
|
||||
if (params.type === 'direct') {
|
||||
const contact = contacts.find((contact) => contact.id === params.recipientId);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(`Contact with id "${params.recipientId}" not found`);
|
||||
}
|
||||
|
||||
participants.push({ id: contact.id, name: contact.name, avatar: contact.avatar });
|
||||
} else {
|
||||
params.recipientIds.forEach((recipientId) => {
|
||||
const contact = contacts.find((contact) => contact.id === recipientId);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(`Contact with id "${recipientId}" not found`);
|
||||
}
|
||||
|
||||
participants.push({ id: contact.id, name: contact.name, avatar: contact.avatar });
|
||||
});
|
||||
}
|
||||
|
||||
thread = { id: `TRD-${Date.now()}`, type: params.type, participants, unreadCount: 0 } satisfies Thread;
|
||||
|
||||
// Add it to the threads
|
||||
const updatedThreads = [thread, ...threads];
|
||||
|
||||
// Dispatch threads update
|
||||
setThreads(updatedThreads);
|
||||
|
||||
return thread.id;
|
||||
},
|
||||
[contacts, threads]
|
||||
);
|
||||
|
||||
const handleMarkAsRead = React.useCallback(
|
||||
(threadId: string) => {
|
||||
const thread = threads.find((thread) => thread.id === threadId);
|
||||
|
||||
if (!thread) {
|
||||
// Thread might no longer exist
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedThreads = threads.map((threadToUpdate) => {
|
||||
if (threadToUpdate.id !== threadId) {
|
||||
return threadToUpdate;
|
||||
}
|
||||
|
||||
return { ...threadToUpdate, unreadCount: 0 };
|
||||
});
|
||||
|
||||
// Dispatch threads update
|
||||
setThreads(updatedThreads);
|
||||
},
|
||||
[threads]
|
||||
);
|
||||
|
||||
const handleCreateMessage = React.useCallback(
|
||||
(params: CreateMessageParams): void => {
|
||||
const message = {
|
||||
id: `MSG-${Date.now()}`,
|
||||
threadId: params.threadId,
|
||||
type: params.type,
|
||||
author: { id: 'USR-000', name: 'Sofia Rivers', avatar: '/assets/avatar.png' },
|
||||
content: params.content,
|
||||
createdAt: new Date(),
|
||||
} satisfies Message;
|
||||
|
||||
const updatedMessages = new Map<string, Message[]>(messages);
|
||||
|
||||
// Add it to the messages
|
||||
if (!updatedMessages.has(params.threadId)) {
|
||||
updatedMessages.set(params.threadId, [message]);
|
||||
} else {
|
||||
updatedMessages.set(params.threadId, [...updatedMessages.get(params.threadId)!, message]);
|
||||
}
|
||||
|
||||
// Dispatch messages update
|
||||
setMessages(updatedMessages);
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
contacts,
|
||||
threads,
|
||||
messages,
|
||||
createThread: handleCreateThread,
|
||||
markAsRead: handleMarkAsRead,
|
||||
createMessage: handleCreateMessage,
|
||||
openDesktopSidebar,
|
||||
setOpenDesktopSidebar,
|
||||
openMobileSidebar,
|
||||
setOpenMobileSidebar,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
90
002_source/cms/src/components/dashboard/chat/chat-view.tsx
Normal file
90
002_source/cms/src/components/dashboard/chat/chat-view.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
|
||||
import { ChatContext } from './chat-context';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
export interface ChatViewProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChatView({ children }: ChatViewProps): React.JSX.Element {
|
||||
const {
|
||||
contacts,
|
||||
threads,
|
||||
messages,
|
||||
createThread,
|
||||
openDesktopSidebar,
|
||||
setOpenDesktopSidebar,
|
||||
openMobileSidebar,
|
||||
setOpenMobileSidebar,
|
||||
} = React.useContext(ChatContext);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
// The layout does not have a direct access to the current thread id param, we need to extract it from the pathname.
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const currentThreadId = segments.length === 4 ? segments[segments.length - 1] : undefined;
|
||||
|
||||
const mdDown = useMediaQuery('down', 'md');
|
||||
|
||||
const handleContactSelect = React.useCallback(
|
||||
(contactId: string) => {
|
||||
const threadId = createThread({ type: 'direct', recipientId: contactId });
|
||||
|
||||
router.push(paths.dashboard.chat.thread('direct', threadId));
|
||||
},
|
||||
[router, createThread]
|
||||
);
|
||||
|
||||
const handleThreadSelect = React.useCallback(
|
||||
(threadType: string, threadId: string) => {
|
||||
router.push(paths.dashboard.chat.thread(threadType, threadId));
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flex: '1 1 0', minHeight: 0 }}>
|
||||
<Sidebar
|
||||
contacts={contacts}
|
||||
currentThreadId={currentThreadId}
|
||||
messages={messages}
|
||||
onCloseMobile={() => {
|
||||
setOpenMobileSidebar(false);
|
||||
}}
|
||||
onSelectContact={handleContactSelect}
|
||||
onSelectThread={handleThreadSelect}
|
||||
openDesktop={openDesktopSidebar}
|
||||
openMobile={openMobileSidebar}
|
||||
threads={threads}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: '1px solid var(--mui-palette-divider)', display: 'flex', flex: '0 0 auto', p: 2 }}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (mdDown) {
|
||||
setOpenMobileSidebar((prev) => !prev);
|
||||
} else {
|
||||
setOpenDesktopSidebar((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
import Divider from '@mui/material/Divider';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
|
||||
import { ChatContext } from './chat-context';
|
||||
import { GroupRecipients } from './group-recipients';
|
||||
import { MessageAdd } from './message-add';
|
||||
import type { Contact, MessageType } from './types';
|
||||
|
||||
function useRecipients(): {
|
||||
handleRecipientAdd: (contact: Contact) => void;
|
||||
handleRecipientRemove: (contactId: string) => void;
|
||||
recipients: Contact[];
|
||||
} {
|
||||
const [recipients, setRecipients] = React.useState<Contact[]>([]);
|
||||
|
||||
const handleRecipientAdd = React.useCallback((recipient: Contact) => {
|
||||
setRecipients((prevState) => {
|
||||
const found = prevState.find((_recipient) => _recipient.id === recipient.id);
|
||||
|
||||
if (found) {
|
||||
return prevState;
|
||||
}
|
||||
|
||||
return [...prevState, recipient];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRecipientRemove = React.useCallback((recipientId: string) => {
|
||||
setRecipients((prevState) => {
|
||||
return prevState.filter((recipient) => recipient.id !== recipientId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { handleRecipientAdd, handleRecipientRemove, recipients };
|
||||
}
|
||||
|
||||
export function ComposeView(): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const { contacts, createThread, createMessage } = React.useContext(ChatContext);
|
||||
|
||||
const { handleRecipientAdd, handleRecipientRemove, recipients } = useRecipients();
|
||||
|
||||
const handleSendMessage = React.useCallback(
|
||||
async (type: MessageType, content: string) => {
|
||||
const recipientIds = recipients.map((recipient) => recipient.id);
|
||||
|
||||
const threadId = createThread({ type: 'group', recipientIds });
|
||||
|
||||
createMessage({ threadId, type, content });
|
||||
|
||||
router.push(paths.dashboard.chat.thread('group', threadId));
|
||||
},
|
||||
[router, createThread, createMessage, recipients]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 }}>
|
||||
<GroupRecipients
|
||||
contacts={contacts}
|
||||
onRecipientAdd={handleRecipientAdd}
|
||||
onRecipientRemove={handleRecipientRemove}
|
||||
recipients={recipients}
|
||||
/>
|
||||
<Divider />
|
||||
<Box sx={{ flex: '1 1 auto' }} />
|
||||
<Divider />
|
||||
<MessageAdd disabled={recipients.length < 1} onSend={handleSendMessage} />
|
||||
</Box>
|
||||
);
|
||||
}
|
115
002_source/cms/src/components/dashboard/chat/direct-search.tsx
Normal file
115
002_source/cms/src/components/dashboard/chat/direct-search.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
|
||||
|
||||
import { Tip } from '@/components/core/tip';
|
||||
|
||||
import type { Contact } from './types';
|
||||
|
||||
export interface DirectSearchProps {
|
||||
isFocused?: boolean;
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClickAway?: () => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onSelect?: (result: Contact) => void;
|
||||
query?: string;
|
||||
results?: Contact[];
|
||||
}
|
||||
|
||||
export const DirectSearch = React.forwardRef<HTMLDivElement, DirectSearchProps>(function ChatSidebarSearch(
|
||||
{
|
||||
isFocused,
|
||||
onChange,
|
||||
onClickAway = () => {
|
||||
// noop
|
||||
},
|
||||
onFocus,
|
||||
onSelect,
|
||||
query = '',
|
||||
results = [],
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const handleSelect = React.useCallback(
|
||||
(result: Contact) => {
|
||||
onSelect?.(result);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
const showTip = isFocused && !query;
|
||||
const showResults = isFocused && query;
|
||||
const hasResults = results.length > 0;
|
||||
|
||||
return (
|
||||
<ClickAwayListener onClickAway={onClickAway}>
|
||||
<Stack ref={ref} spacing={2} tabIndex={-1}>
|
||||
<OutlinedInput
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
placeholder="Search contacts"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
|
||||
</InputAdornment>
|
||||
}
|
||||
value={query}
|
||||
/>
|
||||
{showTip ? <Tip message="Enter a contact name" /> : null}
|
||||
{showResults ? (
|
||||
<React.Fragment>
|
||||
{hasResults ? (
|
||||
<Stack spacing={1}>
|
||||
<Typography color="text.secondary" variant="subtitle2">
|
||||
Contacts
|
||||
</Typography>
|
||||
<List disablePadding sx={{ '& .MuiListItemButton-root': { borderRadius: 1 } }}>
|
||||
{results.map((contact) => (
|
||||
<ListItem disablePadding key={contact.id}>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
handleSelect(contact);
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={contact.avatar} sx={{ '--Avatar-size': '32px' }} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
disableTypography
|
||||
primary={
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{contact.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
) : (
|
||||
<div>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
We couldn't find any matches for "{query}". Try checking for typos or using complete
|
||||
words.
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</Stack>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
});
|
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
|
||||
|
||||
import { logger } from '@/lib/default-logger';
|
||||
|
||||
import type { Contact } from './types';
|
||||
|
||||
export interface GroupRecipientsProps {
|
||||
contacts: Contact[];
|
||||
onRecipientAdd?: (contact: Contact) => void;
|
||||
onRecipientRemove?: (recipientId: string) => void;
|
||||
recipients?: Contact[];
|
||||
}
|
||||
|
||||
export function GroupRecipients({
|
||||
contacts,
|
||||
onRecipientAdd,
|
||||
onRecipientRemove,
|
||||
recipients = [],
|
||||
}: GroupRecipientsProps): React.JSX.Element {
|
||||
const searchRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [searchFocused, setSearchFocused] = React.useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>('');
|
||||
const [searchResults, setSearchResults] = React.useState<Contact[]>([]);
|
||||
|
||||
const showSearchResults = searchFocused && Boolean(searchQuery);
|
||||
const hasSearchResults = searchResults.length > 0;
|
||||
|
||||
const handleSearchChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const query = event.target.value;
|
||||
|
||||
setSearchQuery(query);
|
||||
|
||||
if (!query) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// This is where you would make an API request for a real search. For the sake of simplicity, we are just
|
||||
// filtering the data in the client.
|
||||
const results = contacts.filter((contact) => {
|
||||
// Filter already picked recipients
|
||||
if (recipients.find((recipient) => recipient.id === contact.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return contact.name.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
},
|
||||
[contacts, recipients]
|
||||
);
|
||||
|
||||
const handleSearchClickAway = React.useCallback(() => {
|
||||
if (showSearchResults) {
|
||||
setSearchFocused(false);
|
||||
}
|
||||
}, [showSearchResults]);
|
||||
|
||||
const handleSearchFocus = React.useCallback(() => {
|
||||
setSearchFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleSearchSelect = React.useCallback(
|
||||
(contact: Contact) => {
|
||||
setSearchQuery('');
|
||||
onRecipientAdd?.(contact);
|
||||
},
|
||||
[onRecipientAdd]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', overflowX: 'auto', p: 2 }}>
|
||||
<ClickAwayListener onClickAway={handleSearchClickAway}>
|
||||
<div>
|
||||
<OutlinedInput
|
||||
onChange={handleSearchChange}
|
||||
onFocus={handleSearchFocus}
|
||||
placeholder="Search contacts"
|
||||
ref={searchRef}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{ minWidth: '260px' }}
|
||||
value={searchQuery}
|
||||
/>
|
||||
{showSearchResults ? (
|
||||
<Popper anchorEl={searchRef.current} open={searchFocused} placement="bottom-start">
|
||||
<Paper
|
||||
sx={{
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
boxShadow: 'var(--mui-shadows-16)',
|
||||
maxWidth: '100%',
|
||||
mt: 1,
|
||||
width: '320px',
|
||||
}}
|
||||
>
|
||||
{hasSearchResults ? (
|
||||
<React.Fragment>
|
||||
<Box sx={{ px: 3, py: 2 }}>
|
||||
<Typography color="text.secondary" variant="subtitle2">
|
||||
Contacts
|
||||
</Typography>
|
||||
</Box>
|
||||
<List sx={{ p: 1, '& .MuiListItemButton-root': { borderRadius: 1 } }}>
|
||||
{searchResults.map((contact) => (
|
||||
<ListItem disablePadding key={contact.id}>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
handleSearchSelect(contact);
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={contact.avatar} sx={{ '--Avatar-size': '32px' }} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
disableTypography
|
||||
primary={
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{contact.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Stack spacing={1} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h6">Nothing found</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
We couldn't find any matches for "{searchQuery}". Try checking for typos or using
|
||||
complete words.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Popper>
|
||||
) : null}
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
To:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', overflowX: 'auto' }}>
|
||||
{recipients.map((recipient) => (
|
||||
<Chip
|
||||
avatar={<Avatar src={recipient.avatar} />}
|
||||
key={recipient.id}
|
||||
label={recipient.name}
|
||||
onDelete={() => {
|
||||
onRecipientRemove?.(recipient.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
107
002_source/cms/src/components/dashboard/chat/message-add.tsx
Normal file
107
002_source/cms/src/components/dashboard/chat/message-add.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
|
||||
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
|
||||
import { PaperPlaneTilt as PaperPlaneTiltIcon } from '@phosphor-icons/react/dist/ssr/PaperPlaneTilt';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
import type { MessageType } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export interface MessageAddProps {
|
||||
disabled?: boolean;
|
||||
onSend?: (type: MessageType, content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageAdd({ disabled = false, onSend }: MessageAddProps): React.JSX.Element {
|
||||
const [content, setContent] = React.useState<string>('');
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleAttach = React.useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setContent(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleSend = React.useCallback(() => {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSend?.('text', content);
|
||||
setContent('');
|
||||
}, [content, onSend]);
|
||||
|
||||
const handleKeyUp = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.code === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '0 0 auto', px: 3, py: 1 }}>
|
||||
<Avatar src={user.avatar} sx={{ display: { xs: 'none', sm: 'inline' } }} />
|
||||
<OutlinedInput
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder="Leave a message"
|
||||
sx={{ flex: '1 1 auto' }}
|
||||
value={content}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Tooltip title="Send">
|
||||
<span>
|
||||
<IconButton
|
||||
color="primary"
|
||||
disabled={!content || disabled}
|
||||
onClick={handleSend}
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
color: 'var(--mui-palette-primary-contrastText)',
|
||||
'&:hover': { bgcolor: 'var(--mui-palette-primary-dark)' },
|
||||
}}
|
||||
>
|
||||
<PaperPlaneTiltIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||
<Tooltip title="Attach photo">
|
||||
<span>
|
||||
<IconButton disabled={disabled} edge="end" onClick={handleAttach}>
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Attach file">
|
||||
<span>
|
||||
<IconButton disabled={disabled} edge="end" onClick={handleAttach}>
|
||||
<PaperclipIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<input hidden ref={fileInputRef} type="file" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
84
002_source/cms/src/components/dashboard/chat/message-box.tsx
Normal file
84
002_source/cms/src/components/dashboard/chat/message-box.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Message } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export interface MessageBoxProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBox({ message }: MessageBoxProps): React.JSX.Element {
|
||||
const position = message.author.id === user.id ? 'right' : 'left';
|
||||
|
||||
return (
|
||||
<Box sx={{ alignItems: position === 'right' ? 'flex-end' : 'flex-start', flex: '0 0 auto', display: 'flex' }}>
|
||||
<Stack
|
||||
direction={position === 'right' ? 'row-reverse' : 'row'}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'flex-start',
|
||||
maxWidth: '500px',
|
||||
ml: position === 'right' ? 'auto' : 0,
|
||||
mr: position === 'left' ? 'auto' : 0,
|
||||
}}
|
||||
>
|
||||
<Avatar src={message.author.avatar} sx={{ '--Avatar-size': '32px' }} />
|
||||
<Stack spacing={1} sx={{ flex: '1 1 auto' }}>
|
||||
<Card
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
...(position === 'right' && {
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
color: 'var(--mui-palette-primary-contrastText)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<div>
|
||||
<Link color="inherit" sx={{ cursor: 'pointer' }} variant="subtitle2">
|
||||
{message.author.name}
|
||||
</Link>
|
||||
</div>
|
||||
{message.type === 'image' ? (
|
||||
<CardMedia
|
||||
image={message.content}
|
||||
onClick={() => {
|
||||
// open modal
|
||||
}}
|
||||
sx={{ height: '200px', width: '200px' }}
|
||||
/>
|
||||
) : null}
|
||||
{message.type === 'text' ? (
|
||||
<Typography color="inherit" variant="body1">
|
||||
{message.content}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Box sx={{ display: 'flex', justifyContent: position === 'right' ? 'flex-end' : 'flex-start', px: 2 }}>
|
||||
<Typography color="text.secondary" noWrap variant="caption">
|
||||
{dayjs(message.createdAt).fromNow()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
218
002_source/cms/src/components/dashboard/chat/sidebar.tsx
Normal file
218
002_source/cms/src/components/dashboard/chat/sidebar.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
|
||||
import { DirectSearch } from './direct-search';
|
||||
import { ThreadItem } from './thread-item';
|
||||
import type { Contact, Message, Thread } from './types';
|
||||
|
||||
export interface SidebarProps {
|
||||
contacts: Contact[];
|
||||
currentThreadId?: string;
|
||||
messages: Map<string, Message[]>;
|
||||
onCloseMobile?: () => void;
|
||||
onSelectContact?: (contactId: string) => void;
|
||||
onSelectThread?: (threadType: string, threadId: string) => void;
|
||||
openDesktop?: boolean;
|
||||
openMobile?: boolean;
|
||||
threads: Thread[];
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
contacts,
|
||||
currentThreadId,
|
||||
messages,
|
||||
onCloseMobile,
|
||||
onSelectContact,
|
||||
onSelectThread,
|
||||
openDesktop,
|
||||
openMobile,
|
||||
threads,
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
const mdUp = useMediaQuery('up', 'md');
|
||||
|
||||
const content = (
|
||||
<SidebarContent
|
||||
closeOnGroupClick={!mdUp}
|
||||
closeOnThreadSelect={!mdUp}
|
||||
contacts={contacts}
|
||||
currentThreadId={currentThreadId}
|
||||
messages={messages}
|
||||
onClose={onCloseMobile}
|
||||
onSelectContact={onSelectContact}
|
||||
onSelectThread={onSelectThread}
|
||||
threads={threads}
|
||||
/>
|
||||
);
|
||||
|
||||
if (mdUp) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRight: '1px solid var(--mui-palette-divider)',
|
||||
flex: '0 0 auto',
|
||||
ml: openDesktop ? 0 : '-320px',
|
||||
position: 'relative',
|
||||
transition: 'margin 225ms cubic-bezier(0.0, 0, 0.2, 1) 0ms',
|
||||
width: '320px',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer PaperProps={{ sx: { maxWidth: '100%', width: '320px' } }} onClose={onCloseMobile} open={openMobile}>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
closeOnGroupClick?: boolean;
|
||||
closeOnThreadSelect?: boolean;
|
||||
contacts: Contact[];
|
||||
currentThreadId?: string;
|
||||
messages: Map<string, Message[]>;
|
||||
onClose?: () => void;
|
||||
onSelectContact?: (contactId: string) => void;
|
||||
onSelectThread?: (threadType: string, threadId: string) => void;
|
||||
threads: Thread[];
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
closeOnGroupClick,
|
||||
closeOnThreadSelect,
|
||||
contacts,
|
||||
currentThreadId,
|
||||
messages,
|
||||
onClose,
|
||||
onSelectContact,
|
||||
onSelectThread,
|
||||
threads,
|
||||
}: SidebarContentProps): React.JSX.Element {
|
||||
// If you want to persist the search states, you can move it to the Sidebar component or a context.
|
||||
// Otherwise, the search states will be reset when the window size changes between mobile and desktop.
|
||||
const [searchFocused, setSearchFocused] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>('');
|
||||
const [searchResults, setSearchResults] = React.useState<Contact[]>([]);
|
||||
|
||||
const handleSearchChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const { value } = event.target;
|
||||
|
||||
setSearchQuery(value);
|
||||
|
||||
if (!value) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = contacts.filter((contact) => {
|
||||
return contact.name.toLowerCase().includes(value.toLowerCase());
|
||||
});
|
||||
|
||||
setSearchResults(results);
|
||||
},
|
||||
[contacts]
|
||||
);
|
||||
|
||||
const handleSearchClickAway = React.useCallback(() => {
|
||||
if (searchFocused) {
|
||||
setSearchFocused(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [searchFocused]);
|
||||
|
||||
const handleSearchFocus = React.useCallback(() => {
|
||||
setSearchFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleSearchSelect = React.useCallback(
|
||||
(contact: Contact) => {
|
||||
onSelectContact?.(contact.id);
|
||||
|
||||
setSearchFocused(false);
|
||||
setSearchQuery('');
|
||||
},
|
||||
[onSelectContact]
|
||||
);
|
||||
|
||||
const handleThreadSelect = React.useCallback(
|
||||
(threadType: string, threadId: string) => {
|
||||
onSelectThread?.(threadType, threadId);
|
||||
|
||||
if (closeOnThreadSelect) {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onSelectThread, onClose, closeOnThreadSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '0 0 auto', p: 2 }}>
|
||||
<Typography sx={{ flex: '1 1 auto' }} variant="h5">
|
||||
Chats
|
||||
</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.chat.compose}
|
||||
onClick={() => {
|
||||
if (closeOnGroupClick) {
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
startIcon={<PlusIcon />}
|
||||
variant="contained"
|
||||
>
|
||||
Group
|
||||
</Button>
|
||||
<IconButton onClick={onClose} sx={{ display: { md: 'none' } }}>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack spacing={2} sx={{ flex: '1 1 auto', overflowY: 'auto', p: 2 }}>
|
||||
<DirectSearch
|
||||
isFocused={searchFocused}
|
||||
onChange={handleSearchChange}
|
||||
onClickAway={handleSearchClickAway}
|
||||
onFocus={handleSearchFocus}
|
||||
onSelect={handleSearchSelect}
|
||||
query={searchQuery}
|
||||
results={searchResults}
|
||||
/>
|
||||
<Stack
|
||||
component="ul"
|
||||
spacing={1}
|
||||
sx={{ display: searchFocused ? 'none' : 'flex', listStyle: 'none', m: 0, p: 0 }}
|
||||
>
|
||||
{threads.map((thread) => (
|
||||
<ThreadItem
|
||||
active={currentThreadId === thread.id}
|
||||
key={thread.id}
|
||||
messages={messages.get(thread.id) ?? []}
|
||||
onSelect={() => {
|
||||
handleThreadSelect(thread.type, thread.id);
|
||||
}}
|
||||
thread={thread}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
110
002_source/cms/src/components/dashboard/chat/thread-item.tsx
Normal file
110
002_source/cms/src/components/dashboard/chat/thread-item.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Message, Thread } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
function getDisplayContent(lastMessage: Message, userId: string): string {
|
||||
const author = lastMessage.author.id === userId ? 'Me: ' : '';
|
||||
const message = lastMessage.type === 'image' ? 'Sent a photo' : lastMessage.content;
|
||||
|
||||
return `${author}${message}`;
|
||||
}
|
||||
|
||||
export interface ThreadItemProps {
|
||||
active?: boolean;
|
||||
onSelect?: () => void;
|
||||
thread: Thread;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export function ThreadItem({ active = false, thread, messages, onSelect }: ThreadItemProps): React.JSX.Element {
|
||||
const recipients = (thread.participants ?? []).filter((participant) => participant.id !== user.id);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
return (
|
||||
<Box component="li" sx={{ userSelect: 'none' }}>
|
||||
<Box
|
||||
onClick={onSelect}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
onSelect?.();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flex: '0 0 auto',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
...(active && { bgcolor: 'var(--mui-palette-action-selected)' }),
|
||||
'&:hover': { ...(!active && { bgcolor: 'var(--mui-palette-action-hover)' }) },
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
<AvatarGroup
|
||||
max={2}
|
||||
sx={{
|
||||
'& .MuiAvatar-root': {
|
||||
fontSize: 'var(--fontSize-xs)',
|
||||
...(thread.type === 'group'
|
||||
? { height: '24px', ml: '-16px', width: '24px', '&:nth-of-type(2)': { mt: '12px' } }
|
||||
: { height: '36px', width: '36px' }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{recipients.map((recipient) => (
|
||||
<Avatar key={recipient.id} src={recipient.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
<Box sx={{ flex: '1 1 auto', overflow: 'hidden' }}>
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{recipients.map((recipient) => recipient.name).join(', ')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
{(thread.unreadCount ?? 0) > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
borderRadius: '50%',
|
||||
flex: '0 0 auto',
|
||||
height: '8px',
|
||||
width: '8px',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{lastMessage ? (
|
||||
<Typography color="text.secondary" noWrap sx={{ flex: '1 1 auto' }} variant="subtitle2">
|
||||
{getDisplayContent(lastMessage, user.id)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
{lastMessage ? (
|
||||
<Typography color="text.secondary" sx={{ whiteSpace: 'nowrap' }} variant="caption">
|
||||
{dayjs(lastMessage.createdAt).fromNow()}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
125
002_source/cms/src/components/dashboard/chat/thread-toolbar.tsx
Normal file
125
002_source/cms/src/components/dashboard/chat/thread-toolbar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Archive as ArchiveIcon } from '@phosphor-icons/react/dist/ssr/Archive';
|
||||
import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
|
||||
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { Phone as PhoneIcon } from '@phosphor-icons/react/dist/ssr/Phone';
|
||||
import { Prohibit as ProhibitIcon } from '@phosphor-icons/react/dist/ssr/Prohibit';
|
||||
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
|
||||
import type { Thread } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export interface ThreadToolbarProps {
|
||||
thread: Thread;
|
||||
}
|
||||
|
||||
export function ThreadToolbar({ thread }: ThreadToolbarProps): React.JSX.Element {
|
||||
const popover = usePopover<HTMLButtonElement>();
|
||||
|
||||
const recipients = (thread.participants ?? []).filter((participant) => participant.id !== user.id);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid var(--mui-palette-divider)',
|
||||
flex: '0 0 auto',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '64px',
|
||||
px: 2,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', minWidth: 0 }}>
|
||||
<AvatarGroup
|
||||
max={2}
|
||||
sx={{
|
||||
'& .MuiAvatar-root': {
|
||||
fontSize: 'var(--fontSize-xs)',
|
||||
...(thread.type === 'group'
|
||||
? { height: '24px', ml: '-16px', width: '24px', '&:nth-of-type(2)': { mt: '12px' } }
|
||||
: { height: '36px', width: '36px' }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{recipients.map((recipient) => (
|
||||
<Avatar key={recipient.id} src={recipient.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{recipients.map((recipient) => recipient.name).join(', ')}
|
||||
</Typography>
|
||||
{thread.type === 'direct' ? (
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
Recently active
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<IconButton>
|
||||
<PhoneIcon />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
<Tooltip title="More options">
|
||||
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Menu anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open}>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<ProhibitIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Block</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<TrashIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Delete</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<ArchiveIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Archive</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<BellIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Mute</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
87
002_source/cms/src/components/dashboard/chat/thread-view.tsx
Normal file
87
002_source/cms/src/components/dashboard/chat/thread-view.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { ChatContext } from './chat-context';
|
||||
import { MessageAdd } from './message-add';
|
||||
import { MessageBox } from './message-box';
|
||||
import { ThreadToolbar } from './thread-toolbar';
|
||||
import type { Message, MessageType, Thread, ThreadType } from './types';
|
||||
|
||||
/**
|
||||
* This method is used to get the thread from the context based on the thread type and ID.
|
||||
* The thread should be loaded from the API in the page, but for the sake of simplicity we are just using the context.
|
||||
*/
|
||||
function useThread(threadId: string): Thread | undefined {
|
||||
const { threads } = React.useContext(ChatContext);
|
||||
|
||||
return threads.find((thread) => thread.id === threadId);
|
||||
}
|
||||
|
||||
function useMessages(threadId: string): Message[] {
|
||||
const { messages } = React.useContext(ChatContext);
|
||||
|
||||
return messages.get(threadId) ?? [];
|
||||
}
|
||||
|
||||
export interface ThreadViewProps {
|
||||
threadId: string;
|
||||
threadType: ThreadType;
|
||||
}
|
||||
|
||||
export function ThreadView({ threadId }: ThreadViewProps): React.JSX.Element | null {
|
||||
const { createMessage, markAsRead } = React.useContext(ChatContext);
|
||||
|
||||
const thread = useThread(threadId);
|
||||
|
||||
const messages = useMessages(threadId);
|
||||
|
||||
const messagesRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleThreadChange = React.useCallback(() => {
|
||||
markAsRead(threadId);
|
||||
}, [threadId, markAsRead]);
|
||||
|
||||
React.useEffect(() => {
|
||||
handleThreadChange();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Prevent infinite loop
|
||||
}, [threadId]);
|
||||
|
||||
const handleSendMessage = React.useCallback(
|
||||
async (type: MessageType, content: string) => {
|
||||
createMessage({ threadId, type, content });
|
||||
},
|
||||
[threadId, createMessage]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (messagesRef.current) {
|
||||
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (!thread) {
|
||||
return (
|
||||
<Box sx={{ alignItems: 'center', display: 'flex', flex: '1 1 auto', justifyContent: 'center' }}>
|
||||
<Typography color="textSecondary" variant="h6">
|
||||
Thread not found
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 }}>
|
||||
<ThreadToolbar thread={thread} />
|
||||
<Stack ref={messagesRef} spacing={2} sx={{ flex: '1 1 auto', overflowY: 'auto', p: 3 }}>
|
||||
{messages.map((message) => (
|
||||
<MessageBox key={message.id} message={message} />
|
||||
))}
|
||||
</Stack>
|
||||
<MessageAdd onSend={handleSendMessage} />
|
||||
</Box>
|
||||
);
|
||||
}
|
33
002_source/cms/src/components/dashboard/chat/types.d.ts
vendored
Normal file
33
002_source/cms/src/components/dashboard/chat/types.d.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Contact {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
isActive: boolean;
|
||||
lastActivity?: Date;
|
||||
}
|
||||
|
||||
export type ThreadType = 'direct' | 'group';
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
type: ThreadType;
|
||||
participants: Participant[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export type MessageType = 'text' | 'image';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
threadId: string;
|
||||
type: MessageType;
|
||||
content: string;
|
||||
author: { id: string; name: string; avatar?: string };
|
||||
createdAt: Date;
|
||||
}
|
Reference in New Issue
Block a user