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
This commit is contained in:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

181
src/pages/ChatPage.tsx Normal file
View File

@@ -0,0 +1,181 @@
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>
);
}