Files
microdao-daarion/src/pages/ChatPage.tsx
Apple 3de3c8cb36 feat: Add presence heartbeat for Matrix online status
- matrix-gateway: POST /internal/matrix/presence/online endpoint
- usePresenceHeartbeat hook with activity tracking
- Auto away after 5 min inactivity
- Offline on page close/visibility change
- Integrated in MatrixChatRoom component
2025-11-27 00:19:40 -08:00

182 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTeam } from '../api/teams';
import { getChannels, getChannelMessages, createMessage } from '../api/channels';
import { MessageSquare, Plus, Send, Loader2 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import type { CreateMessageRequest } from '../types/api';
export function ChatPage() {
const { teamId, channelId } = useParams<{ teamId: string; channelId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [messageText, setMessageText] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: team } = useQuery({
queryKey: ['team', teamId],
queryFn: () => getTeam(teamId!),
enabled: !!teamId,
});
const { data: channelsData } = useQuery({
queryKey: ['channels', teamId],
queryFn: () => getChannels(teamId!),
enabled: !!teamId,
});
const { data: messagesData, isLoading: messagesLoading } = useQuery({
queryKey: ['messages', channelId],
queryFn: () => getChannelMessages(channelId!),
enabled: !!channelId,
});
const currentChannel = channelsData?.channels.find((c) => c.id === channelId);
const sendMessageMutation = useMutation({
mutationFn: (data: CreateMessageRequest) => createMessage(channelId!, data),
onSuccess: () => {
setMessageText('');
queryClient.invalidateQueries({ queryKey: ['messages', channelId] });
},
});
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!messageText.trim() || sendMessageMutation.isPending) return;
sendMessageMutation.mutate({ content: messageText.trim() });
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messagesData?.messages]);
if (messagesLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
const messages = messagesData?.messages || [];
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<div className="w-64 bg-white border-r border-gray-200 flex flex-col">
{/* Team Header */}
<div className="p-4 border-b border-gray-200">
<h1 className="text-lg font-semibold text-gray-900 truncate">{team?.name || 'Завантаження...'}</h1>
{team?.description && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{team.description}</p>
)}
</div>
{/* Channels List */}
<div className="flex-1 overflow-y-auto p-2">
<div className="flex items-center justify-between mb-2 px-2">
<h2 className="text-xs font-semibold text-gray-500 uppercase">Канали</h2>
<button
className="text-gray-400 hover:text-gray-600"
title="Створити канал"
>
<Plus className="w-4 h-4" />
</button>
</div>
<div className="space-y-1">
{channelsData?.channels.map((channel) => (
<button
key={channel.id}
onClick={() => navigate(`/teams/${teamId}/channels/${channel.id}`)}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors ${
channel.id === channelId
? 'bg-blue-50 text-blue-700'
: 'hover:bg-gray-100 text-gray-700'
}`}
>
<MessageSquare className="w-4 h-4" />
<span className="text-sm truncate">{channel.name}</span>
</button>
))}
</div>
</div>
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col">
{/* Chat Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<h2 className="text-lg font-semibold text-gray-900">{currentChannel?.name || 'Канал'}</h2>
<p className="text-sm text-gray-500 mt-1">
{currentChannel?.type === 'public' ? 'Публічний канал' : 'Приватна кімната'}
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<MessageSquare className="w-12 h-12 text-gray-400 mx-auto mb-2" />
<p className="text-gray-500">Повідомлень поки немає</p>
<p className="text-sm text-gray-400 mt-1">Почніть розмову!</p>
</div>
</div>
) : (
messages.map((message) => (
<div key={message.id} className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-semibold">
{message.user?.name?.[0]?.toUpperCase() || message.user?.email?.[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1">
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-gray-900">
{message.user?.name || message.user?.email || 'Анонім'}
</span>
<span className="text-xs text-gray-500">
{new Date(message.created_at).toLocaleTimeString('uk-UA', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<p className="text-gray-700 whitespace-pre-wrap">{message.content}</p>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Message Composer */}
<div className="bg-white border-t border-gray-200 p-4">
<form onSubmit={handleSendMessage} className="flex gap-2">
<input
type="text"
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="Написати повідомлення..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={sendMessageMutation.isPending}
/>
<button
type="submit"
disabled={!messageText.trim() || sendMessageMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
>
{sendMessageMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</form>
</div>
</div>
</div>
);
}