505 lines
19 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|