init
This commit is contained in:
98
src/features/chat/components/chat-area.tsx
Normal file
98
src/features/chat/components/chat-area.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useRef } from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
||||
import type { Attachment, Conversation } from '../utils/types';
|
||||
import { ChatHeader } from './chat-header';
|
||||
import { MessageBubble } from './message-bubble';
|
||||
import { MessageComposer } from './message-composer';
|
||||
|
||||
interface ChatAreaProps {
|
||||
conversation: Conversation;
|
||||
draft: string;
|
||||
onDraftChange: (text: string) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
attachments: Attachment[];
|
||||
onAddAttachments: (files: FileList) => void;
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
conversation,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
attachments,
|
||||
onAddAttachments,
|
||||
onRemoveAttachment
|
||||
}: ChatAreaProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const liveRegionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
const container = messagesContainerRef.current;
|
||||
const behavior = shouldReduceMotion ? 'auto' : 'smooth';
|
||||
|
||||
const scrollToBottom = () => {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
};
|
||||
|
||||
if (behavior === 'smooth') {
|
||||
requestAnimationFrame(scrollToBottom);
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [conversation.messages, conversation.id, shouldReduceMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveRegionRef.current) return;
|
||||
const lastMessage = conversation.messages[conversation.messages.length - 1];
|
||||
if (!lastMessage) return;
|
||||
liveRegionRef.current.textContent =
|
||||
lastMessage.author + ' at ' + lastMessage.timestamp + ': ' + lastMessage.text;
|
||||
}, [conversation.messages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence initial={false} mode='wait'>
|
||||
<motion.div
|
||||
key={conversation.id}
|
||||
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 12 }}
|
||||
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
|
||||
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.32, ease: 'easeOut' }}
|
||||
className='border-border/40 bg-background/80 flex min-h-0 flex-col gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:p-4 lg:col-start-2 lg:col-end-3 lg:rounded-3xl'
|
||||
>
|
||||
<ChatHeader conversation={conversation} />
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className='[&::-webkit-scrollbar-thumb]:bg-muted relative min-h-0 flex-1 space-y-3 overflow-y-auto pr-2 sm:space-y-4 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full'
|
||||
aria-live='off'
|
||||
aria-label={'Message thread with ' + conversation.name}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<MessageComposer
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
onSubmit={onSubmit}
|
||||
contactName={conversation.name}
|
||||
quickReplies={conversation.quickReplies}
|
||||
attachments={attachments}
|
||||
onAddAttachments={onAddAttachments}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div ref={liveRegionRef} className='sr-only' aria-live='polite' aria-atomic='true' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
src/features/chat/components/chat-header.tsx
Normal file
73
src/features/chat/components/chat-header.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Conversation } from '../utils/types';
|
||||
|
||||
const statusDotColor = {
|
||||
online: 'bg-green-500',
|
||||
offline: 'bg-red-500'
|
||||
} as const;
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
export function ChatHeader({ conversation }: ChatHeaderProps) {
|
||||
return (
|
||||
<header className='flex flex-wrap items-center justify-between gap-3 sm:gap-4'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<div className='relative'>
|
||||
<Avatar className='border-border/40 bg-card/80 text-foreground h-10 w-10 rounded-2xl border sm:h-12 sm:w-12 sm:rounded-3xl'>
|
||||
<AvatarFallback className='bg-primary/20 text-primary rounded-2xl text-sm font-semibold sm:rounded-3xl sm:text-base'>
|
||||
{conversation.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2 sm:h-3.5 sm:w-3.5',
|
||||
statusDotColor[conversation.status]
|
||||
)}
|
||||
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-foreground text-sm font-semibold sm:text-base'>{conversation.name}</p>
|
||||
<p className='text-muted-foreground text-xs sm:text-sm'>{conversation.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-1.5 sm:gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Start audio call'
|
||||
>
|
||||
<Icons.phone className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Start video call'
|
||||
>
|
||||
<Icons.video className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/60 text-muted-foreground hover:bg-muted/60 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Open conversation menu'
|
||||
>
|
||||
<Icons.ellipsis className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
11
src/features/chat/components/chat-view-page.tsx
Normal file
11
src/features/chat/components/chat-view-page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Messenger } from './messenger';
|
||||
|
||||
export default function ChatViewPage() {
|
||||
return (
|
||||
<div className='flex min-h-0 flex-1 px-4 py-2 md:px-6'>
|
||||
<Messenger />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/features/chat/components/conversation-list.tsx
Normal file
140
src/features/chat/components/conversation-list.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { motion } from 'motion/react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Conversation } from '../utils/types';
|
||||
|
||||
const statusDotColor = {
|
||||
online: 'bg-green-500',
|
||||
offline: 'bg-red-500'
|
||||
} as const;
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return conversations;
|
||||
const q = search.toLowerCase();
|
||||
return conversations.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.title.toLowerCase().includes(q)
|
||||
);
|
||||
}, [conversations, search]);
|
||||
|
||||
return (
|
||||
<div className='border-border/40 bg-background/75 hidden h-full flex-col gap-4 overflow-hidden rounded-2xl border p-3 backdrop-blur lg:col-start-1 lg:col-end-2 lg:flex lg:rounded-3xl lg:p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-foreground text-sm font-semibold'>Messenger</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{conversations.length} active conversation
|
||||
{conversations.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-3 py-1 text-[0.7rem] tracking-[0.24em] uppercase'
|
||||
>
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<label htmlFor='messenger-search' className='sr-only'>
|
||||
Search conversations
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<Icons.search
|
||||
className='text-muted-foreground/70 pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<Input
|
||||
id='messenger-search'
|
||||
type='search'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder='Search conversations'
|
||||
className='border-border/40 bg-background/60 text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-primary/40 w-full rounded-2xl pl-10 text-sm focus-visible:ring-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='flex-1 space-y-2 overflow-y-auto pr-1'
|
||||
aria-label='Conversation list'
|
||||
role='list'
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className='text-muted-foreground py-8 text-center text-xs'>No conversations found</p>
|
||||
) : null}
|
||||
{filtered.map((conversation) => {
|
||||
const isActive = conversation.id === selectedId;
|
||||
const lastMessage = conversation.messages[conversation.messages.length - 1];
|
||||
return (
|
||||
<motion.button
|
||||
key={conversation.id}
|
||||
type='button'
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'focus-visible:ring-primary/50 group focus-visible:ring-offset-background relative flex w-full items-start gap-3 rounded-2xl border border-transparent p-3 text-left transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
isActive
|
||||
? 'border-primary/40 bg-primary/10'
|
||||
: 'bg-background/70 hover:border-border/40 hover:bg-muted/40'
|
||||
)}
|
||||
role='listitem'
|
||||
>
|
||||
<div className='relative shrink-0'>
|
||||
<Avatar className='border-border/40 bg-background/80 text-foreground h-10 w-10 rounded-2xl border'>
|
||||
<AvatarFallback className='bg-primary/15 text-primary rounded-2xl text-sm font-medium'>
|
||||
{conversation.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
'border-background absolute right-0 bottom-0 inline-flex h-3 w-3 rounded-full border-2',
|
||||
statusDotColor[conversation.status]
|
||||
)}
|
||||
aria-label={conversation.status === 'online' ? 'Online' : 'Offline'}
|
||||
/>
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 space-y-1'>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='text-foreground text-sm font-semibold'>{conversation.name}</p>
|
||||
<p className='text-muted-foreground text-xs'>{conversation.title}</p>
|
||||
</div>
|
||||
{lastMessage && (
|
||||
<span className='text-muted-foreground shrink-0 text-[0.65rem]'>
|
||||
{lastMessage.timestamp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lastMessage ? (
|
||||
<p className='text-muted-foreground line-clamp-2 text-xs'>
|
||||
{lastMessage.author}: {lastMessage.text}
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs'>No messages yet</p>
|
||||
)}
|
||||
</div>
|
||||
{conversation.unread > 0 && (
|
||||
<span className='bg-primary text-primary-foreground ml-2 inline-flex min-h-[1.5rem] min-w-[1.5rem] items-center justify-center rounded-full text-[0.7rem] font-semibold shadow-lg'>
|
||||
{conversation.unread}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/features/chat/components/conversation-select.tsx
Normal file
56
src/features/chat/components/conversation-select.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Conversation } from '../utils/types';
|
||||
|
||||
interface ConversationSelectProps {
|
||||
conversations: Conversation[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ConversationSelect({
|
||||
conversations,
|
||||
selectedId,
|
||||
onSelect
|
||||
}: ConversationSelectProps) {
|
||||
return (
|
||||
<div className='border-border/40 bg-background/75 flex flex-col gap-3 rounded-2xl border p-3 backdrop-blur sm:gap-4 sm:rounded-3xl sm:p-4 lg:hidden'>
|
||||
<div className='flex items-center justify-between gap-2 sm:gap-3'>
|
||||
<div>
|
||||
<p className='text-foreground text-xs font-semibold sm:text-sm'>Messenger</p>
|
||||
<p className='text-muted-foreground text-[0.65rem] sm:text-xs'>
|
||||
{conversations.length} active conversation
|
||||
{conversations.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='bg-primary/15 text-primary hover:bg-primary/15 hover:text-primary border-border/50 rounded-full border px-2 py-0.5 text-[0.65rem] tracking-[0.2em] uppercase sm:px-3 sm:py-1 sm:text-[0.7rem] sm:tracking-[0.24em]'
|
||||
>
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='space-y-1.5 sm:space-y-2'>
|
||||
<label
|
||||
htmlFor='messenger-conversation'
|
||||
className='text-muted-foreground text-[0.65rem] font-medium sm:text-xs'
|
||||
>
|
||||
Conversation
|
||||
</label>
|
||||
<select
|
||||
id='messenger-conversation'
|
||||
value={selectedId}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
className='border-border/40 bg-background/70 text-foreground focus:border-primary/40 focus:ring-primary/30 w-full rounded-xl border px-2.5 py-1.5 text-xs focus:ring-2 focus:outline-none sm:rounded-2xl sm:px-3 sm:py-2 sm:text-sm'
|
||||
>
|
||||
{conversations.map((conversation) => (
|
||||
<option key={conversation.id} value={conversation.id}>
|
||||
{conversation.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/features/chat/components/message-bubble.tsx
Normal file
78
src/features/chat/components/message-bubble.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FilePreview } from '@/components/ui/file-preview';
|
||||
import type { Message } from '../utils/types';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const isUser = message.sender === 'user';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
className='flex flex-col gap-1'
|
||||
role='group'
|
||||
aria-label={message.author + ' at ' + message.timestamp}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative max-w-[85%] rounded-xl border px-3 py-2 text-xs leading-relaxed sm:max-w-[82%] sm:rounded-2xl sm:px-4 sm:py-3 sm:text-sm',
|
||||
isUser
|
||||
? 'border-primary/40 bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted border-transparent'
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium sm:text-sm',
|
||||
isUser ? 'text-primary-foreground/80' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{message.author}
|
||||
</p>
|
||||
{message.text && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-[0.875rem] sm:text-[0.95rem]',
|
||||
isUser ? 'text-primary-foreground/90' : 'text-foreground/90'
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<FilePreview
|
||||
files={message.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: a.type
|
||||
}))}
|
||||
variant={isUser ? 'inverted' : 'default'}
|
||||
className='mt-1 p-0'
|
||||
/>
|
||||
)}
|
||||
<div className='mt-2 flex items-center justify-end gap-1.5 text-[0.65rem] sm:mt-3 sm:gap-2 sm:text-[0.7rem]'>
|
||||
<span className={cn('text-muted-foreground', isUser && 'text-primary-foreground/80')}>
|
||||
{message.timestamp}
|
||||
</span>
|
||||
{isUser && (
|
||||
<Icons.checks
|
||||
className='text-primary-foreground/80 h-3 w-3 sm:h-3.5 sm:w-3.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
119
src/features/chat/components/message-composer.tsx
Normal file
119
src/features/chat/components/message-composer.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useRef } from 'react';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FilePreview } from '@/components/ui/file-preview';
|
||||
import type { Attachment } from '../utils/types';
|
||||
|
||||
interface MessageComposerProps {
|
||||
draft: string;
|
||||
onDraftChange: (text: string) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
contactName: string;
|
||||
quickReplies: string[];
|
||||
attachments: Attachment[];
|
||||
onAddAttachments: (files: FileList) => void;
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MessageComposer({
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
contactName,
|
||||
quickReplies,
|
||||
attachments,
|
||||
onAddAttachments,
|
||||
onRemoveAttachment
|
||||
}: MessageComposerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className='space-y-2 sm:space-y-3' aria-label='Reply composer'>
|
||||
<label htmlFor='messenger-editor' className='sr-only'>
|
||||
Write a message
|
||||
</label>
|
||||
<div className='border-border/40 bg-background/80 flex items-end gap-2 rounded-2xl border p-3 backdrop-blur sm:gap-3 sm:rounded-3xl sm:p-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
{attachments.length > 0 && (
|
||||
<FilePreview
|
||||
files={attachments.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: a.type
|
||||
}))}
|
||||
onRemove={onRemoveAttachment}
|
||||
className='mb-1 p-0'
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
id='messenger-editor'
|
||||
value={draft}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (draft.trim() || attachments.length > 0) {
|
||||
const form = e.currentTarget.closest('form');
|
||||
form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={'Message ' + contactName + ' (Enter to send, Shift+Enter for newline)'}
|
||||
rows={2}
|
||||
className='text-foreground placeholder:text-muted-foreground/70 min-h-[3rem] w-full resize-none border-none bg-transparent text-xs focus-visible:ring-0 focus-visible:outline-none sm:min-h-[4rem] sm:text-sm'
|
||||
aria-label={'Message ' + contactName}
|
||||
/>
|
||||
<div className='mt-2 flex flex-wrap gap-1.5 sm:mt-3 sm:gap-2'>
|
||||
{quickReplies.map((reply) => (
|
||||
<button
|
||||
key={reply}
|
||||
type='button'
|
||||
onClick={() => onDraftChange(reply)}
|
||||
className='border-border/50 bg-background/70 text-muted-foreground hover:border-primary/40 hover:text-foreground focus-visible:ring-primary/40 focus-visible:ring-offset-background rounded-full border px-2.5 py-0.5 text-[0.65rem] transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:px-3 sm:py-1 sm:text-xs'
|
||||
>
|
||||
{reply}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 flex-col items-end gap-1.5 sm:w-24 sm:gap-2'>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
multiple
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddAttachments(e.target.files);
|
||||
}
|
||||
// Reset so same file can be re-selected
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='border-border/40 bg-background/70 text-muted-foreground hover:bg-muted/50 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full border transition focus-visible:ring-2 focus-visible:ring-offset-2 sm:size-10'
|
||||
aria-label='Attach a file'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Icons.paperclip className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
size='icon'
|
||||
className='bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/40 focus-visible:ring-offset-background size-8 rounded-full shadow-lg transition focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 sm:size-10'
|
||||
disabled={!draft.trim() && attachments.length === 0}
|
||||
aria-label='Send message'
|
||||
>
|
||||
<Icons.send className='h-3.5 w-3.5 sm:h-4 sm:w-4' aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
129
src/features/chat/components/messenger.tsx
Normal file
129
src/features/chat/components/messenger.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useReducedMotion } from 'motion/react';
|
||||
import { useChatStore } from '../utils/store';
|
||||
import type { Attachment, Message } from '../utils/types';
|
||||
import { ConversationList } from './conversation-list';
|
||||
import { ConversationSelect } from './conversation-select';
|
||||
import { ChatArea } from './chat-area';
|
||||
|
||||
export function Messenger() {
|
||||
const {
|
||||
conversations,
|
||||
selectedConversationId,
|
||||
draft,
|
||||
replyCursor,
|
||||
selectConversation,
|
||||
setDraft,
|
||||
sendMessage,
|
||||
addIncomingMessage,
|
||||
advanceReplyCursor,
|
||||
getActiveConversation
|
||||
} = useChatStore();
|
||||
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const replyTimeoutRef = useRef<number | null>(null);
|
||||
const selectedRef = useRef(selectedConversationId);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selectedConversationId;
|
||||
setAttachments([]);
|
||||
}, [selectedConversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (replyTimeoutRef.current) {
|
||||
window.clearTimeout(replyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddAttachments = useCallback((files: FileList) => {
|
||||
const newAttachments: Attachment[] = Array.from(files).map((file) => ({
|
||||
id: 'file-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}));
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const active = getActiveConversation();
|
||||
if ((!draft.trim() && attachments.length === 0) || !active) return;
|
||||
|
||||
const conversationId = active.id;
|
||||
sendMessage(draft, attachments.length > 0 ? attachments : undefined);
|
||||
setAttachments([]);
|
||||
|
||||
const autoReplies = active.autoReplies;
|
||||
if (!autoReplies.length) return;
|
||||
|
||||
const cursor = replyCursor[conversationId] ?? 0;
|
||||
const nextReply = autoReplies[cursor % autoReplies.length];
|
||||
const delay = shouldReduceMotion ? 0 : 900;
|
||||
|
||||
replyTimeoutRef.current = window.setTimeout(() => {
|
||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const incoming: Message = {
|
||||
id: 'incoming-' + Date.now().toString(),
|
||||
sender: 'contact',
|
||||
author: active.name,
|
||||
text: nextReply,
|
||||
timestamp
|
||||
};
|
||||
|
||||
addIncomingMessage(conversationId, incoming);
|
||||
advanceReplyCursor(conversationId);
|
||||
}, delay);
|
||||
},
|
||||
[
|
||||
draft,
|
||||
attachments,
|
||||
replyCursor,
|
||||
shouldReduceMotion,
|
||||
getActiveConversation,
|
||||
sendMessage,
|
||||
addIncomingMessage,
|
||||
advanceReplyCursor
|
||||
]
|
||||
);
|
||||
|
||||
const activeConversation = getActiveConversation();
|
||||
if (!activeConversation) return null;
|
||||
|
||||
return (
|
||||
<div className='border-border/50 bg-background/70 relative grid h-[calc(100dvh-5.5rem)] w-full grid-rows-[auto,1fr] gap-3 overflow-hidden rounded-2xl border p-3 backdrop-blur-xl sm:gap-4 sm:p-4 lg:[grid-template-columns:30%_1fr] lg:grid-rows-[1fr] lg:gap-4 lg:rounded-3xl lg:p-5'>
|
||||
<ConversationSelect
|
||||
conversations={conversations}
|
||||
selectedId={selectedConversationId}
|
||||
onSelect={selectConversation}
|
||||
/>
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
selectedId={selectedConversationId}
|
||||
onSelect={selectConversation}
|
||||
/>
|
||||
<ChatArea
|
||||
conversation={activeConversation}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={handleSubmit}
|
||||
attachments={attachments}
|
||||
onAddAttachments={handleAddAttachments}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/features/chat/utils/data.ts
Normal file
127
src/features/chat/utils/data.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Conversation } from './types';
|
||||
|
||||
export const initialConversations: Conversation[] = [
|
||||
{
|
||||
id: 'billing-issue',
|
||||
name: 'Alex from Support',
|
||||
title: 'Billing Issue #4821',
|
||||
status: 'online',
|
||||
unread: 2,
|
||||
initials: 'AS',
|
||||
messages: [
|
||||
{
|
||||
id: 'billing-1',
|
||||
sender: 'contact',
|
||||
author: 'Alex',
|
||||
text: "Hi there! I can see you were charged twice for the Pro plan this month. I've already initiated a refund for the duplicate charge.",
|
||||
timestamp: '10:02'
|
||||
},
|
||||
{
|
||||
id: 'billing-2',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: 'Thanks for catching that. How long will the refund take to process?',
|
||||
timestamp: '10:05'
|
||||
},
|
||||
{
|
||||
id: 'billing-3',
|
||||
sender: 'contact',
|
||||
author: 'Alex',
|
||||
text: 'Typically 3-5 business days depending on your bank. You should see a pending credit within 24 hours though. Is there anything else I can help with?',
|
||||
timestamp: '10:08'
|
||||
}
|
||||
],
|
||||
quickReplies: [
|
||||
"That's perfect, thank you!",
|
||||
'Can I get a receipt for the refund?',
|
||||
'I also have a question about upgrading.'
|
||||
],
|
||||
autoReplies: [
|
||||
"You're welcome! I've also applied a 10% discount on your next billing cycle as an apology for the inconvenience.",
|
||||
'Absolutely — I just emailed the refund confirmation to your registered address.',
|
||||
"Of course! I'd be happy to walk you through the available plans."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'api-integration',
|
||||
name: 'Priya from Engineering',
|
||||
title: 'API Integration Help',
|
||||
status: 'online',
|
||||
unread: 0,
|
||||
initials: 'PE',
|
||||
messages: [
|
||||
{
|
||||
id: 'api-1',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: "I'm getting a 429 rate limit error when calling the /api/products endpoint. We're only making about 50 requests per minute.",
|
||||
timestamp: '09:15'
|
||||
},
|
||||
{
|
||||
id: 'api-2',
|
||||
sender: 'contact',
|
||||
author: 'Priya',
|
||||
text: "I checked your API key — it's on the Starter tier which has a 30 req/min limit. You'll need the Growth plan for 200 req/min. Would you like me to upgrade it?",
|
||||
timestamp: '09:18'
|
||||
},
|
||||
{
|
||||
id: 'api-3',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: 'Yes please. Also, is there a way to implement retry logic that respects the Retry-After header?',
|
||||
timestamp: '09:22'
|
||||
},
|
||||
{
|
||||
id: 'api-4',
|
||||
sender: 'contact',
|
||||
author: 'Priya',
|
||||
text: "Great question — our SDK handles this automatically if you enable `autoRetry: true` in the config. I'll send you a code snippet.",
|
||||
timestamp: '09:25'
|
||||
}
|
||||
],
|
||||
quickReplies: [
|
||||
'That would be very helpful.',
|
||||
'Can you also share the rate limit docs?',
|
||||
"We're also seeing timeouts on the webhook endpoint."
|
||||
],
|
||||
autoReplies: [
|
||||
"Here's the code snippet — just add `autoRetry: true` and `maxRetries: 3` to your client config.",
|
||||
"Sure! I've shared the rate limiting guide in your inbox. It covers burst limits too.",
|
||||
"Let me check the webhook logs for your account. Can you share the endpoint URL you're using?"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'account-access',
|
||||
name: 'Jordan from Security',
|
||||
title: 'Account Access Request',
|
||||
status: 'offline',
|
||||
unread: 1,
|
||||
initials: 'JS',
|
||||
messages: [
|
||||
{
|
||||
id: 'access-1',
|
||||
sender: 'contact',
|
||||
author: 'Jordan',
|
||||
text: "We noticed a login attempt from an unrecognized device in São Paulo. Was this you? We've temporarily locked the session as a precaution.",
|
||||
timestamp: 'Yesterday'
|
||||
},
|
||||
{
|
||||
id: 'access-2',
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: "No, that wasn't me. I'm based in New York. Can you revoke that session and enable 2FA on my account?",
|
||||
timestamp: 'Yesterday'
|
||||
}
|
||||
],
|
||||
quickReplies: [
|
||||
'Can I also see a list of all active sessions?',
|
||||
'Please reset my password as well.',
|
||||
'Has any data been accessed from that session?'
|
||||
],
|
||||
autoReplies: [
|
||||
"I've revoked all sessions except your current one and enabled 2FA. You'll get an email with the setup QR code.",
|
||||
"Done — you'll receive a password reset link shortly. Make sure to use a unique password.",
|
||||
'No data was accessed — the session was blocked before any API calls were made. Your account is secure.'
|
||||
]
|
||||
}
|
||||
];
|
||||
93
src/features/chat/utils/store.ts
Normal file
93
src/features/chat/utils/store.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { create } from 'zustand';
|
||||
// import { persist } from 'zustand/middleware';
|
||||
import type { Attachment, Conversation, Message } from './types';
|
||||
import { initialConversations } from './data';
|
||||
|
||||
type ReplyCursorState = Record<string, number>;
|
||||
|
||||
type ChatState = {
|
||||
conversations: Conversation[];
|
||||
selectedConversationId: string;
|
||||
draft: string;
|
||||
replyCursor: ReplyCursorState;
|
||||
|
||||
selectConversation: (id: string) => void;
|
||||
setDraft: (text: string) => void;
|
||||
sendMessage: (text: string, attachments?: Attachment[]) => void;
|
||||
addIncomingMessage: (conversationId: string, message: Message) => void;
|
||||
advanceReplyCursor: (conversationId: string) => void;
|
||||
getActiveConversation: () => Conversation | undefined;
|
||||
};
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
// To enable persistence across refreshes, uncomment the persist wrapper below:
|
||||
// persist(
|
||||
(set, get) => ({
|
||||
conversations: initialConversations,
|
||||
selectedConversationId: initialConversations[0]?.id ?? '',
|
||||
draft: '',
|
||||
replyCursor: Object.fromEntries(initialConversations.map((c) => [c.id, 0])),
|
||||
|
||||
selectConversation: (id) =>
|
||||
set((state) => ({
|
||||
selectedConversationId: id,
|
||||
conversations: state.conversations.map((c) => (c.id === id ? { ...c, unread: 0 } : c))
|
||||
})),
|
||||
|
||||
setDraft: (text) => set({ draft: text }),
|
||||
|
||||
sendMessage: (text, attachments) => {
|
||||
const state = get();
|
||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const outgoing: Message = {
|
||||
id: 'outgoing-' + Date.now().toString(),
|
||||
sender: 'user',
|
||||
author: 'You',
|
||||
text: text.trim(),
|
||||
timestamp,
|
||||
attachments: attachments?.length ? attachments : undefined
|
||||
};
|
||||
|
||||
set({
|
||||
draft: '',
|
||||
conversations: state.conversations.map((c) =>
|
||||
c.id === state.selectedConversationId
|
||||
? { ...c, messages: [...c.messages, outgoing], unread: 0 }
|
||||
: c
|
||||
)
|
||||
});
|
||||
},
|
||||
|
||||
addIncomingMessage: (conversationId, message) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((c) => {
|
||||
if (c.id !== conversationId) return c;
|
||||
const isActive = state.selectedConversationId === conversationId;
|
||||
return {
|
||||
...c,
|
||||
messages: [...c.messages, message],
|
||||
unread: isActive ? 0 : c.unread + 1
|
||||
};
|
||||
})
|
||||
})),
|
||||
|
||||
advanceReplyCursor: (conversationId) =>
|
||||
set((state) => ({
|
||||
replyCursor: {
|
||||
...state.replyCursor,
|
||||
[conversationId]: (state.replyCursor[conversationId] ?? 0) + 1
|
||||
}
|
||||
})),
|
||||
|
||||
getActiveConversation: () => {
|
||||
const state = get();
|
||||
return state.conversations.find((c) => c.id === state.selectedConversationId);
|
||||
}
|
||||
})
|
||||
// ,
|
||||
// { name: 'chat' }
|
||||
// )
|
||||
);
|
||||
29
src/features/chat/utils/types.ts
Normal file
29
src/features/chat/utils/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
sender: 'user' | 'contact';
|
||||
author: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
|
||||
export type ConversationStatus = 'online' | 'offline';
|
||||
|
||||
export type Conversation = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
status: ConversationStatus;
|
||||
unread: number;
|
||||
initials: string;
|
||||
messages: Message[];
|
||||
quickReplies: string[];
|
||||
autoReplies: string[];
|
||||
};
|
||||
Reference in New Issue
Block a user