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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user