feat: Implement Matrix Chat Client

This commit is contained in:
Apple
2025-11-26 13:15:01 -08:00
parent 871812ef92
commit e9c04f6bcd
7 changed files with 1264 additions and 8 deletions

View File

@@ -1,9 +1,9 @@
import Link from 'next/link'
import { ArrowLeft, Users, FileText, Clock } from 'lucide-react'
import { ArrowLeft, Users, FileText, Clock, MessageCircle } from 'lucide-react'
import { api, CityRoom } from '@/lib/api'
import { formatDate } from '@/lib/utils'
import { notFound } from 'next/navigation'
import { ChatRoom } from '@/components/chat/ChatRoom'
import { MatrixChatRoom } from '@/components/chat/MatrixChatRoom'
// Force dynamic rendering - don't prerender at build time
export const dynamic = 'force-dynamic'
@@ -73,12 +73,21 @@ export default async function RoomPage({ params }: PageProps) {
{/* Chat Area */}
<div className="lg:col-span-2">
<div className="glass-panel h-[500px] sm:h-[600px] flex flex-col overflow-hidden">
<ChatRoom
roomId={room.id}
roomSlug={room.slug}
initialMessages={[]}
/>
<MatrixChatRoom roomSlug={room.slug} />
</div>
{/* Matrix Room Info */}
{room.matrix_room_id && (
<div className="mt-4 glass-panel p-4">
<div className="flex items-center gap-2 text-sm">
<MessageCircle className="w-4 h-4 text-cyan-400" />
<span className="text-slate-400">Matrix Room:</span>
<code className="text-xs font-mono text-cyan-400 bg-slate-800/50 px-2 py-0.5 rounded">
{room.matrix_room_alias || room.matrix_room_id}
</code>
</div>
</div>
)}
</div>
{/* Sidebar */}

View File

@@ -0,0 +1,305 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { MessageSquare, Wifi, WifiOff, Loader2, RefreshCw, AlertCircle } from 'lucide-react'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { MatrixRestClient, createMatrixClient, ChatMessage as MatrixChatMessage } from '@/lib/matrix-client'
import { cn } from '@/lib/utils'
import { useAuth } from '@/context/AuthContext'
interface MatrixChatRoomProps {
roomSlug: string
}
type ConnectionStatus = 'loading' | 'connecting' | 'online' | 'error' | 'unauthenticated'
interface BootstrapData {
matrix_hs_url: string
matrix_user_id: string
matrix_access_token: string
matrix_device_id: string
matrix_room_id: string
matrix_room_alias: string
room: {
id: string
slug: string
name: string
description?: string
}
}
export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
const { user, token } = useAuth()
const [messages, setMessages] = useState<MatrixChatMessage[]>([])
const [status, setStatus] = useState<ConnectionStatus>('loading')
const [error, setError] = useState<string | null>(null)
const [bootstrap, setBootstrap] = useState<BootstrapData | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const matrixClient = useRef<MatrixRestClient | null>(null)
// Scroll to bottom when new messages arrive
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, scrollToBottom])
// Initialize Matrix connection
const initializeMatrix = useCallback(async () => {
if (!token) {
setStatus('unauthenticated')
return
}
setStatus('loading')
setError(null)
try {
// 1. Get bootstrap data
const res = await fetch(`/api/city/chat/bootstrap?room_slug=${roomSlug}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || 'Failed to get chat bootstrap')
}
const data: BootstrapData = await res.json()
setBootstrap(data)
// 2. Create Matrix client
setStatus('connecting')
const client = createMatrixClient(data)
matrixClient.current = client
// 3. Join room
try {
await client.joinRoom(data.matrix_room_id)
} catch (e) {
// Ignore join errors (might already be in room)
console.log('Join room result:', e)
}
// 4. Get initial messages
const messagesRes = await client.getMessages(data.matrix_room_id, { limit: 50 })
const initialMessages = messagesRes.chunk
.filter(e => e.type === 'm.room.message' && e.content?.body)
.map(e => client.mapToChatMessage(e))
.reverse() // Oldest first
setMessages(initialMessages)
// 5. Start sync for real-time updates
await client.initialSync()
client.startSync((newMessage) => {
setMessages(prev => {
// Avoid duplicates
if (prev.some(m => m.id === newMessage.id)) {
return prev
}
return [...prev, newMessage]
})
})
setStatus('online')
} catch (err) {
console.error('Matrix initialization error:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}, [token, roomSlug])
useEffect(() => {
initializeMatrix()
return () => {
matrixClient.current?.stopSync()
}
}, [initializeMatrix])
const handleSendMessage = async (body: string) => {
if (!matrixClient.current || !bootstrap) return
try {
// Optimistically add message
const tempId = `temp_${Date.now()}`
const tempMessage: MatrixChatMessage = {
id: tempId,
senderId: bootstrap.matrix_user_id,
senderName: 'You',
text: body,
timestamp: new Date(),
isUser: true
}
setMessages(prev => [...prev, tempMessage])
// Send to Matrix
const result = await matrixClient.current.sendMessage(bootstrap.matrix_room_id, body)
// Update temp message with real ID
setMessages(prev => prev.map(m =>
m.id === tempId ? { ...m, id: result.event_id } : m
))
} catch (err) {
console.error('Failed to send message:', err)
// Remove failed message
setMessages(prev => prev.filter(m => !m.id.startsWith('temp_')))
setError('Не вдалося надіслати повідомлення')
}
}
const handleRetry = () => {
initializeMatrix()
}
// Map MatrixChatMessage to legacy format for ChatMessage component
const mapToLegacyFormat = (msg: MatrixChatMessage) => ({
id: msg.id,
room_id: bootstrap?.room.id || '',
author_user_id: msg.isUser ? 'current_user' : msg.senderId,
author_agent_id: null,
body: msg.text,
created_at: msg.timestamp.toISOString()
})
return (
<div className="flex flex-col h-full">
{/* Connection status */}
<div className="px-4 py-2 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-medium text-white">Matrix Chat</span>
{bootstrap?.matrix_room_alias && (
<span className="text-xs text-slate-500 font-mono">
{bootstrap.matrix_room_alias}
</span>
)}
</div>
<div className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-full text-xs',
status === 'loading' && 'bg-slate-500/20 text-slate-400',
status === 'connecting' && 'bg-amber-500/20 text-amber-400',
status === 'online' && 'bg-emerald-500/20 text-emerald-400',
status === 'error' && 'bg-red-500/20 text-red-400',
status === 'unauthenticated' && 'bg-amber-500/20 text-amber-400'
)}>
{status === 'loading' && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span>Завантаження...</span>
</>
)}
{status === 'connecting' && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span>Підключення до Matrix...</span>
</>
)}
{status === 'online' && (
<>
<Wifi className="w-3 h-3" />
<span>Онлайн</span>
</>
)}
{status === 'error' && (
<>
<WifiOff className="w-3 h-3" />
<span>Помилка</span>
</>
)}
{status === 'unauthenticated' && (
<>
<AlertCircle className="w-3 h-3" />
<span>Потрібен вхід</span>
</>
)}
</div>
</div>
{/* Error / Auth required message */}
{(status === 'error' || status === 'unauthenticated') && (
<div className="px-4 py-3 bg-red-500/10 border-b border-red-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-red-400">
<AlertCircle className="w-4 h-4" />
<span>
{status === 'unauthenticated'
? 'Увійдіть, щоб приєднатися до чату'
: error || 'Помилка підключення'
}
</span>
</div>
{status === 'error' && (
<button
onClick={handleRetry}
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-full transition-colors"
>
<RefreshCw className="w-3 h-3" />
Повторити
</button>
)}
</div>
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto py-4 space-y-1">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-16 h-16 mb-4 rounded-full bg-slate-800/50 flex items-center justify-center">
<MessageSquare className="w-8 h-8 text-slate-600" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{status === 'online'
? 'Поки що немає повідомлень'
: status === 'unauthenticated'
? 'Увійдіть для доступу до чату'
: 'Підключення до Matrix...'
}
</h3>
<p className="text-sm text-slate-400 max-w-sm">
{status === 'online'
? 'Будьте першим, хто напише в цій кімнаті! Ваше повідомлення синхронізується з Matrix.'
: status === 'unauthenticated'
? 'Для участі в чаті потрібна авторизація'
: 'Встановлюємо зʼєднання з Matrix сервером...'
}
</p>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={mapToLegacyFormat(message)}
isOwn={message.isUser}
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input area */}
<ChatInput
onSend={handleSendMessage}
disabled={status !== 'online'}
placeholder={
status === 'online'
? 'Напишіть повідомлення...'
: status === 'unauthenticated'
? 'Увійдіть для надсилання повідомлень'
: 'Очікування підключення...'
}
/>
</div>
)
}

View File

@@ -26,6 +26,9 @@ export interface CityRoom {
created_by: string | null
members_online: number
last_event: string | null
// Matrix integration
matrix_room_id: string | null
matrix_room_alias: string | null
}
export interface SecondMeProfile {

View File

@@ -0,0 +1,315 @@
/**
* Lightweight Matrix REST Client for DAARION
*
* Uses Matrix Client-Server API directly without heavy SDK
*/
export interface MatrixClientConfig {
baseUrl: string;
accessToken: string;
userId: string;
roomId?: string;
}
export interface MatrixMessage {
event_id: string;
sender: string;
origin_server_ts: number;
content: {
msgtype: string;
body: string;
format?: string;
formatted_body?: string;
};
type: string;
}
export interface MatrixMessagesResponse {
chunk: MatrixMessage[];
start: string;
end: string;
}
export interface MatrixSyncResponse {
next_batch: string;
rooms?: {
join?: {
[roomId: string]: {
timeline?: {
events: MatrixMessage[];
prev_batch?: string;
};
state?: {
events: any[];
};
};
};
};
}
export interface ChatMessage {
id: string;
senderId: string;
senderName: string;
text: string;
timestamp: Date;
isUser: boolean;
}
export class MatrixRestClient {
private baseUrl: string;
private accessToken: string;
private userId: string;
private roomId: string | null = null;
private syncToken: string | null = null;
private syncAbortController: AbortController | null = null;
private onMessageCallback: ((message: ChatMessage) => void) | null = null;
private isSyncing: boolean = false;
constructor(config: MatrixClientConfig) {
this.baseUrl = config.baseUrl;
this.accessToken = config.accessToken;
this.userId = config.userId;
this.roomId = config.roomId || null;
}
private authHeaders(): HeadersInit {
return {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json'
};
}
/**
* Join a Matrix room
*/
async joinRoom(roomId: string): Promise<{ room_id: string }> {
const res = await fetch(
`${this.baseUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`,
{
method: 'POST',
headers: this.authHeaders()
}
);
if (!res.ok) {
const error = await res.json();
// M_FORBIDDEN means already joined or not allowed
if (error.errcode !== 'M_FORBIDDEN') {
throw new Error(error.error || 'Failed to join room');
}
}
this.roomId = roomId;
return res.json();
}
/**
* Get messages from a room
*/
async getMessages(roomId: string, options?: { limit?: number; from?: string; dir?: 'b' | 'f' }): Promise<MatrixMessagesResponse> {
const params = new URLSearchParams({
dir: options?.dir || 'b',
limit: String(options?.limit || 50),
filter: JSON.stringify({ types: ['m.room.message'] })
});
if (options?.from) {
params.set('from', options.from);
}
const res = await fetch(
`${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?${params}`,
{ headers: this.authHeaders() }
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to get messages');
}
return res.json();
}
/**
* Send a text message to a room
*/
async sendMessage(roomId: string, body: string): Promise<{ event_id: string }> {
const txnId = `m${Date.now()}.${Math.random().toString(36).substr(2, 9)}`;
const res = await fetch(
`${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
{
method: 'PUT',
headers: this.authHeaders(),
body: JSON.stringify({
msgtype: 'm.text',
body: body
})
}
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to send message');
}
return res.json();
}
/**
* Perform initial sync to get sync token
*/
async initialSync(): Promise<MatrixSyncResponse> {
const params = new URLSearchParams({
timeout: '0',
filter: JSON.stringify({
room: {
timeline: { limit: 1 },
state: { lazy_load_members: true }
}
})
});
const res = await fetch(
`${this.baseUrl}/_matrix/client/v3/sync?${params}`,
{ headers: this.authHeaders() }
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to sync');
}
const data = await res.json();
this.syncToken = data.next_batch;
return data;
}
/**
* Start long-polling for new messages
*/
startSync(onMessage: (message: ChatMessage) => void): void {
this.onMessageCallback = onMessage;
this.isSyncing = true;
this.syncLoop();
}
/**
* Stop syncing
*/
stopSync(): void {
this.isSyncing = false;
if (this.syncAbortController) {
this.syncAbortController.abort();
this.syncAbortController = null;
}
}
private async syncLoop(): Promise<void> {
while (this.isSyncing) {
try {
this.syncAbortController = new AbortController();
const params = new URLSearchParams({
timeout: '30000',
filter: JSON.stringify({
room: {
timeline: { limit: 50 },
state: { lazy_load_members: true }
}
})
});
if (this.syncToken) {
params.set('since', this.syncToken);
}
const res = await fetch(
`${this.baseUrl}/_matrix/client/v3/sync?${params}`,
{
headers: this.authHeaders(),
signal: this.syncAbortController.signal
}
);
if (!res.ok) {
console.error('Sync failed:', await res.text());
// Wait before retry
await new Promise(r => setTimeout(r, 5000));
continue;
}
const data: MatrixSyncResponse = await res.json();
this.syncToken = data.next_batch;
// Process new messages
if (data.rooms?.join && this.roomId) {
const roomData = data.rooms.join[this.roomId];
if (roomData?.timeline?.events) {
for (const event of roomData.timeline.events) {
if (event.type === 'm.room.message' && event.content?.body) {
const chatMessage = this.mapToChatMessage(event);
this.onMessageCallback?.(chatMessage);
}
}
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Sync was stopped
break;
}
console.error('Sync error:', error);
// Wait before retry
await new Promise(r => setTimeout(r, 5000));
}
}
}
/**
* Map Matrix event to ChatMessage
*/
mapToChatMessage(event: MatrixMessage): ChatMessage {
// Extract display name from Matrix user ID
// @daarion_abc123:daarion.space -> User abc123
const senderName = event.sender
.split(':')[0]
.replace('@daarion_', 'User ')
.replace('@', '');
return {
id: event.event_id,
senderId: event.sender,
senderName: senderName,
text: event.content.body,
timestamp: new Date(event.origin_server_ts),
isUser: event.sender === this.userId
};
}
/**
* Get current user ID
*/
getUserId(): string {
return this.userId;
}
}
/**
* Create a Matrix client from bootstrap data
*/
export function createMatrixClient(bootstrap: {
matrix_hs_url: string;
matrix_user_id: string;
matrix_access_token: string;
matrix_room_id: string;
}): MatrixRestClient {
return new MatrixRestClient({
baseUrl: bootstrap.matrix_hs_url,
accessToken: bootstrap.matrix_access_token,
userId: bootstrap.matrix_user_id,
roomId: bootstrap.matrix_room_id
});
}