build ok,

This commit is contained in:
louiscklaw
2025-04-14 09:26:24 +08:00
commit 6c931c1fe8
770 changed files with 63959 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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&apos;t find any matches for &quot;{query}&quot;. Try checking for typos or using complete
words.
</Typography>
</div>
)}
</React.Fragment>
) : null}
</Stack>
</ClickAwayListener>
);
});

View File

@@ -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&apos;t find any matches for &quot;{searchQuery}&quot;. 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}