Files
Feeld/web/src/pages/Chat.tsx
2026-03-20 18:49:48 -05:00

505 lines
19 KiB
TypeScript
Executable File

import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@apollo/client/react';
import { useState, useRef, useEffect } from 'react';
import { GET_CHAT_SUMMARY_QUERY } from '../api/operations/queries';
import { useChannel, useStreamChat } from '../context/StreamChatContext';
import type { StreamMessage } from '../context/StreamChatContext';
import { LoadingPage } from '../components/ui/Loading';
// Proxy cloudinary images through our server to avoid CORS
function proxyImageUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
if (url.includes('cloudinary.com')) {
return url.replace('https://res.cloudinary.com', '/api/images');
}
if (url.includes('fldcdn.com')) {
return url.replace('https://prod.fldcdn.com', '/api/fldcdn');
}
return url;
}
function formatTime(dateString: string | undefined): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function formatDate(dateString: string | undefined): string {
if (!dateString) return '';
const date = new Date(dateString);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
} else {
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
}
interface MessageBubbleProps {
message: StreamMessage;
isOwn: boolean;
showDate?: boolean;
showAvatar?: boolean;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
}
function MessageBubble({ message, isOwn, showDate, showAvatar, isFirstInGroup, isLastInGroup }: MessageBubbleProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
// Get border radius based on position in group
const getBorderRadius = () => {
if (isOwn) {
if (isFirstInGroup && isLastInGroup) return 'rounded-[20px] rounded-br-md';
if (isFirstInGroup) return 'rounded-[20px] rounded-br-md rounded-br-md';
if (isLastInGroup) return 'rounded-[20px] rounded-tr-md rounded-br-md';
return 'rounded-[20px] rounded-r-md';
} else {
if (isFirstInGroup && isLastInGroup) return 'rounded-[20px] rounded-bl-md';
if (isFirstInGroup) return 'rounded-[20px] rounded-bl-md';
if (isLastInGroup) return 'rounded-[20px] rounded-tl-md rounded-bl-md';
return 'rounded-[20px] rounded-l-md';
}
};
return (
<>
{showDate && (
<div className="flex justify-center my-6">
<span className="
text-[11px] font-medium tracking-wide uppercase
text-[var(--color-text-muted)]/70
bg-white/[0.03] backdrop-blur-sm
px-4 py-1.5 rounded-full
border border-white/[0.04]
">
{formatDate(message.created_at)}
</span>
</div>
)}
<div className={`
flex ${isOwn ? 'justify-end' : 'justify-start'}
${isLastInGroup ? 'mb-3' : 'mb-0.5'}
animate-fade-up
`}>
<div className={`
flex items-end gap-2
${isOwn ? 'flex-row-reverse' : 'flex-row'}
max-w-[80%] md:max-w-[65%]
`}>
{/* Avatar placeholder for alignment */}
{!isOwn && (
<div className="w-8 h-8 flex-shrink-0">
{showAvatar && message.user?.image && (
<img
src={proxyImageUrl(message.user.image)}
alt=""
className="w-8 h-8 rounded-full object-cover ring-2 ring-white/5"
/>
)}
</div>
)}
<div className="flex flex-col">
<div className={`
px-4 py-2.5
${getBorderRadius()}
${isOwn
? 'bg-gradient-to-br from-[#E85F5C] to-[#D64550] text-white'
: 'bg-white/[0.07] text-[var(--color-text-primary)] border border-white/[0.05]'
}
shadow-lg
${isOwn ? 'shadow-[#E85F5C]/10' : 'shadow-black/20'}
`}>
{/* Attachments */}
{message.attachments?.map((att, i) => {
const imageUrl = proxyImageUrl(att.image_url || att.thumb_url);
if (!imageUrl) return null;
return (
<div key={i} className="relative mb-2 -mx-1 -mt-1 first:-mt-1">
{!imageLoaded && !imageError && (
<div className="w-48 h-48 bg-white/5 rounded-xl animate-pulse flex items-center justify-center">
<svg className="w-8 h-8 text-white/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
{imageError ? (
<div className="w-48 h-32 bg-white/5 rounded-xl flex items-center justify-center">
<span className="text-sm text-white/40">Image unavailable</span>
</div>
) : (
<img
src={imageUrl}
alt="attachment"
className={`max-w-[200px] rounded-xl ${imageLoaded ? 'block' : 'hidden'}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
)}
</div>
);
})}
{/* Message text */}
{message.text && (
<p className="text-[15px] leading-relaxed whitespace-pre-wrap break-words">
{message.text}
</p>
)}
</div>
{/* Timestamp - only show for last message in group */}
{isLastInGroup && (
<p className={`
text-[10px] text-[var(--color-text-muted)]/60 mt-1 px-1
${isOwn ? 'text-right' : 'text-left'}
`}>
{formatTime(message.created_at)}
</p>
)}
</div>
</div>
</div>
</>
);
}
export function ChatPage() {
const { channelId } = useParams<{ channelId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const chatName = searchParams.get('name') || 'Chat';
const chatAvatar = searchParams.get('avatar') || undefined;
const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Stream Chat connection
const { isConnected, isConnecting: streamConnecting, error: streamError, userId } = useStreamChat();
// Channel and messages
const {
messages,
loading: channelLoading,
error: channelError,
sendMessage,
} = useChannel(channelId || '');
// Get chat summary for additional info
const { data: summaryData, loading: summaryLoading } = useQuery(GET_CHAT_SUMMARY_QUERY, {
variables: { streamChatId: channelId },
skip: !channelId,
});
const summary = summaryData?.summary;
const displayName = summary?.name || chatName;
const displayAvatar = proxyImageUrl(summary?.avatarSet?.[0] || chatAvatar);
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
if (!inputText.trim() || isSending) return;
try {
setIsSending(true);
await sendMessage(inputText.trim());
setInputText('');
inputRef.current?.focus();
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsSending(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Loading states
if (summaryLoading || streamConnecting) {
return <LoadingPage message="Connecting to chat..." />;
}
// Stream connection error
if (streamError || channelError) {
return (
<div className="flex flex-col items-center justify-center py-20 animate-fade-up">
<div className="
w-20 h-20 rounded-full
bg-gradient-to-br from-red-500/20 to-red-600/10
flex items-center justify-center mb-6
border border-red-500/20
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-10 h-10 text-red-400">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<h2 className="font-display text-xl font-semibold text-[var(--color-text-primary)] mb-2">
Connection Lost
</h2>
<p className="text-[var(--color-text-muted)] text-sm max-w-xs text-center mb-6">
{streamError || channelError}
</p>
<button
onClick={() => navigate('/messages')}
className="
px-6 py-2.5 rounded-full
bg-white/10 hover:bg-white/15
text-white font-medium
transition-all duration-200
border border-white/10
"
>
Back to Messages
</button>
</div>
);
}
// Process messages for grouping
let lastDate = '';
let lastUserId = '';
const processedMessages = messages.map((msg, index) => {
const msgDate = msg.created_at ? formatDate(msg.created_at) : '';
const showDate = msgDate !== lastDate;
lastDate = msgDate;
const currentUserId = msg.user?.id || '';
const nextMsg = messages[index + 1];
const nextUserId = nextMsg?.user?.id || '';
const isFirstInGroup = showDate || currentUserId !== lastUserId;
const isLastInGroup = !nextMsg || nextUserId !== currentUserId ||
(nextMsg.created_at && msg.created_at &&
new Date(nextMsg.created_at).getTime() - new Date(msg.created_at).getTime() > 60000);
lastUserId = currentUserId;
return {
...msg,
showDate,
showAvatar: isLastInGroup && msg.user?.id !== userId,
isFirstInGroup,
isLastInGroup,
};
});
return (
<div className="flex flex-col h-[calc(100vh-80px)] max-w-4xl mx-auto -mx-4 md:mx-auto">
{/* Header */}
<div className="
flex items-center gap-4 px-4 py-3
bg-gradient-to-b from-[var(--color-surface)] to-transparent
backdrop-blur-xl
sticky top-0 z-10
">
<button
onClick={() => navigate('/messages')}
className="
w-10 h-10 rounded-full
bg-white/[0.05] hover:bg-white/[0.08]
flex items-center justify-center
transition-all duration-200
border border-white/[0.06]
hover:scale-105 active:scale-95
"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="w-5 h-5 text-white/70">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Avatar with glow effect */}
<div className="relative">
{displayAvatar ? (
<img
src={displayAvatar}
alt=""
className="w-11 h-11 rounded-full object-cover ring-2 ring-white/10"
/>
) : (
<div className="w-11 h-11 rounded-full bg-gradient-to-br from-[var(--color-primary)]/30 to-[var(--color-primary)]/10 flex items-center justify-center ring-2 ring-white/10">
<span className="text-lg font-semibold text-white/70">
{displayName.charAt(0)}
</span>
</div>
)}
{/* Online indicator */}
<div className={`
absolute -bottom-0.5 -right-0.5
w-3.5 h-3.5 rounded-full
${isConnected ? 'bg-emerald-400' : 'bg-amber-400'}
border-2 border-[var(--color-void)]
`} />
</div>
<div className="flex-1 min-w-0">
<h2 className="font-semibold text-[var(--color-text-primary)] truncate text-lg">
{displayName}
</h2>
{summary?.targetProfile && (
<p className="text-sm text-[var(--color-text-muted)]/70">
{summary.targetProfile.age} · {summary.targetProfile.gender?.toLowerCase()}
</p>
)}
</div>
</div>
{/* More options */}
<button className="
w-10 h-10 rounded-full
hover:bg-white/[0.05]
flex items-center justify-center
transition-colors
">
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 text-white/50">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</button>
</div>
{/* Messages area */}
<div className="
flex-1 overflow-y-auto
px-4 py-4
scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent
">
{channelLoading ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<div className="
w-12 h-12 rounded-full
border-2 border-[var(--color-primary)]/30 border-t-[var(--color-primary)]
animate-spin
" />
<p className="text-[var(--color-text-muted)]/60 text-sm">Loading messages...</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 animate-fade-up">
<div className="
w-24 h-24 rounded-full
bg-gradient-to-br from-white/[0.05] to-white/[0.02]
flex items-center justify-center mb-6
border border-white/[0.05]
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="w-12 h-12 text-[var(--color-text-muted)]/30">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<h3 className="text-lg font-medium text-[var(--color-text-primary)] mb-2">
Start the conversation
</h3>
<p className="text-[var(--color-text-muted)]/60 text-sm text-center max-w-xs">
Send a message to begin chatting with {displayName}
</p>
</div>
) : (
<>
{processedMessages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
isOwn={msg.user?.id === userId}
showDate={msg.showDate}
showAvatar={msg.showAvatar}
isFirstInGroup={msg.isFirstInGroup}
isLastInGroup={msg.isLastInGroup}
/>
))}
</>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="
px-4 py-3
bg-gradient-to-t from-[var(--color-void)] via-[var(--color-void)] to-transparent
border-t border-white/[0.04]
">
<div className="flex items-end gap-3">
{/* Attachment button */}
<button className="
w-11 h-11 rounded-full
bg-white/[0.05] hover:bg-white/[0.08]
flex items-center justify-center
transition-all duration-200
border border-white/[0.06]
flex-shrink-0
">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-5 h-5 text-white/50">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
{/* Input field */}
<div className="flex-1 relative">
<input
ref={inputRef}
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type a message..."
disabled={!isConnected}
className="
w-full px-5 py-3
bg-white/[0.05]
border border-white/[0.08]
rounded-full
text-[var(--color-text-primary)] text-[15px]
placeholder-[var(--color-text-muted)]/50
focus:outline-none focus:border-[var(--color-primary)]/40 focus:bg-white/[0.07]
transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed
"
/>
</div>
{/* Send button */}
<button
onClick={handleSend}
disabled={!inputText.trim() || isSending || !isConnected}
className={`
w-11 h-11 rounded-full
flex items-center justify-center
transition-all duration-200
flex-shrink-0
${inputText.trim() && !isSending && isConnected
? 'bg-gradient-to-br from-[#E85F5C] to-[#D64550] text-white shadow-lg shadow-[#E85F5C]/25 hover:shadow-[#E85F5C]/40 hover:scale-105 active:scale-95'
: 'bg-white/[0.05] text-white/30 border border-white/[0.06] cursor-not-allowed'
}
`}
>
{isSending ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 translate-x-0.5">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
)}
</button>
</div>
</div>
</div>
);
}