feat: Implement Matrix Presence & Typing indicators
- MATRIX_PRESENCE_TYPING_SPEC.md documentation - MatrixRestClient: sync-loop with presence+typing events - MatrixChatRoom: onlineUsers and typingUsers state - UI: Show N online in header - UI: Typing indicator with animation - ChatInput: onTyping callback support
This commit is contained in:
@@ -6,11 +6,12 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void
|
onSend: (message: string) => void
|
||||||
|
onTyping?: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled = false, placeholder = 'Напишіть повідомлення...' }: ChatInputProps) {
|
export function ChatInput({ onSend, onTyping, disabled = false, placeholder = 'Напишіть повідомлення...' }: ChatInputProps) {
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
@@ -29,6 +30,14 @@ export function ChatInput({ onSend, disabled = false, placeholder = 'Напиш
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setMessage(e.target.value)
|
||||||
|
// Notify about typing
|
||||||
|
if (e.target.value && onTyping) {
|
||||||
|
onTyping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
@@ -54,7 +63,7 @@ export function ChatInput({ onSend, disabled = false, placeholder = 'Напиш
|
|||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { MessageSquare, Wifi, WifiOff, Loader2, RefreshCw, AlertCircle } from 'lucide-react'
|
import { MessageSquare, Wifi, WifiOff, Loader2, RefreshCw, AlertCircle, Users } from 'lucide-react'
|
||||||
import { ChatMessage } from './ChatMessage'
|
import { ChatMessage } from './ChatMessage'
|
||||||
import { ChatInput } from './ChatInput'
|
import { ChatInput } from './ChatInput'
|
||||||
import { MatrixRestClient, createMatrixClient, ChatMessage as MatrixChatMessage } from '@/lib/matrix-client'
|
import { MatrixRestClient, createMatrixClient, ChatMessage as MatrixChatMessage, PresenceEvent } from '@/lib/matrix-client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { getAccessToken } from '@/lib/auth'
|
import { getAccessToken } from '@/lib/auth'
|
||||||
@@ -30,6 +30,14 @@ interface BootstrapData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to format user name from Matrix ID
|
||||||
|
function formatUserName(userId: string): string {
|
||||||
|
return userId
|
||||||
|
.split(':')[0]
|
||||||
|
.replace('@daarion_', 'User ')
|
||||||
|
.replace('@', '');
|
||||||
|
}
|
||||||
|
|
||||||
export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
@@ -40,6 +48,11 @@ export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const matrixClient = useRef<MatrixRestClient | null>(null)
|
const matrixClient = useRef<MatrixRestClient | null>(null)
|
||||||
|
|
||||||
|
// Presence & Typing state
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState<Map<string, 'online' | 'offline' | 'unavailable'>>(new Map())
|
||||||
|
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set())
|
||||||
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
// Scroll to bottom when new messages arrive
|
// Scroll to bottom when new messages arrive
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -97,7 +110,26 @@ export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
|||||||
|
|
||||||
setMessages(initialMessages)
|
setMessages(initialMessages)
|
||||||
|
|
||||||
// 5. Start sync for real-time updates
|
// 5. Set up presence and typing handlers
|
||||||
|
client.onPresence = (event: PresenceEvent) => {
|
||||||
|
if (!event.sender || !event.content?.presence) return;
|
||||||
|
|
||||||
|
setOnlineUsers(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(event.sender, event.content.presence);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
client.onTyping = (roomId: string, userIds: string[]) => {
|
||||||
|
if (roomId !== data.matrix_room_id) return;
|
||||||
|
|
||||||
|
// Filter out current user
|
||||||
|
const others = userIds.filter(id => id !== data.matrix_user_id);
|
||||||
|
setTypingUsers(new Set(others));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. Start sync for real-time updates
|
||||||
await client.initialSync()
|
await client.initialSync()
|
||||||
client.startSync((newMessage) => {
|
client.startSync((newMessage) => {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
@@ -121,14 +153,61 @@ export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
|||||||
initializeMatrix()
|
initializeMatrix()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
matrixClient.current?.stopSync()
|
if (matrixClient.current) {
|
||||||
|
matrixClient.current.onPresence = undefined;
|
||||||
|
matrixClient.current.onTyping = undefined;
|
||||||
|
matrixClient.current.stopSync();
|
||||||
|
}
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [initializeMatrix])
|
}, [initializeMatrix])
|
||||||
|
|
||||||
|
// Calculate online count
|
||||||
|
const onlineCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
onlineUsers.forEach((status, userId) => {
|
||||||
|
if (status === 'online' || status === 'unavailable') {
|
||||||
|
// Don't count current user
|
||||||
|
if (userId !== bootstrap?.matrix_user_id) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}, [onlineUsers, bootstrap]);
|
||||||
|
|
||||||
|
// Handle typing notification
|
||||||
|
const handleTyping = useCallback(() => {
|
||||||
|
if (!matrixClient.current || !bootstrap) return;
|
||||||
|
|
||||||
|
// Send typing notification
|
||||||
|
matrixClient.current.sendTyping(bootstrap.matrix_room_id, true);
|
||||||
|
|
||||||
|
// Clear previous timeout
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop typing after 3 seconds of inactivity
|
||||||
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (matrixClient.current && bootstrap) {
|
||||||
|
matrixClient.current.sendTyping(bootstrap.matrix_room_id, false);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}, [bootstrap]);
|
||||||
|
|
||||||
const handleSendMessage = async (body: string) => {
|
const handleSendMessage = async (body: string) => {
|
||||||
if (!matrixClient.current || !bootstrap) return
|
if (!matrixClient.current || !bootstrap) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Stop typing indicator
|
||||||
|
matrixClient.current.sendTyping(bootstrap.matrix_room_id, false);
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistically add message
|
// Optimistically add message
|
||||||
const tempId = `temp_${Date.now()}`
|
const tempId = `temp_${Date.now()}`
|
||||||
const tempMessage: MatrixChatMessage = {
|
const tempMessage: MatrixChatMessage = {
|
||||||
@@ -176,11 +255,17 @@ export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
|||||||
<div className="px-4 py-2 border-b border-white/10 flex items-center justify-between">
|
<div className="px-4 py-2 border-b border-white/10 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MessageSquare className="w-4 h-4 text-cyan-400" />
|
<MessageSquare className="w-4 h-4 text-cyan-400" />
|
||||||
<span className="text-sm font-medium text-white">Matrix Chat</span>
|
<span className="text-sm font-medium text-white">
|
||||||
{bootstrap?.matrix_room_alias && (
|
{bootstrap?.room.name || 'Matrix Chat'}
|
||||||
<span className="text-xs text-slate-500 font-mono">
|
|
||||||
{bootstrap.matrix_room_alias}
|
|
||||||
</span>
|
</span>
|
||||||
|
{status === 'online' && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500">·</span>
|
||||||
|
<div className="flex items-center gap-1 text-emerald-400 text-xs">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
<span>{onlineCount} online</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -289,9 +374,26 @@ export function MatrixChatRoom({ roomSlug }: MatrixChatRoomProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Typing indicator */}
|
||||||
|
{typingUsers.size > 0 && (
|
||||||
|
<div className="px-4 py-1.5 text-sm text-cyan-400/80 animate-pulse flex items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{typingUsers.size === 1
|
||||||
|
? `${formatUserName(Array.from(typingUsers)[0])} друкує...`
|
||||||
|
: 'Декілька учасників друкують...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
|
onTyping={handleTyping}
|
||||||
disabled={status !== 'online'}
|
disabled={status !== 'online'}
|
||||||
placeholder={
|
placeholder={
|
||||||
status === 'online'
|
status === 'online'
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export interface MatrixMessagesResponse {
|
|||||||
|
|
||||||
export interface MatrixSyncResponse {
|
export interface MatrixSyncResponse {
|
||||||
next_batch: string;
|
next_batch: string;
|
||||||
|
presence?: {
|
||||||
|
events: PresenceEvent[];
|
||||||
|
};
|
||||||
rooms?: {
|
rooms?: {
|
||||||
join?: {
|
join?: {
|
||||||
[roomId: string]: {
|
[roomId: string]: {
|
||||||
@@ -42,9 +45,32 @@ export interface MatrixSyncResponse {
|
|||||||
state?: {
|
state?: {
|
||||||
events: any[];
|
events: any[];
|
||||||
};
|
};
|
||||||
|
ephemeral?: {
|
||||||
|
events: EphemeralEvent[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceEvent {
|
||||||
|
type: 'm.presence';
|
||||||
|
sender: string;
|
||||||
|
content: {
|
||||||
|
presence: 'online' | 'offline' | 'unavailable';
|
||||||
|
last_active_ago?: number;
|
||||||
|
currently_active?: boolean;
|
||||||
|
status_msg?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EphemeralEvent {
|
||||||
|
type: string;
|
||||||
|
content: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypingContent {
|
||||||
|
user_ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -66,6 +92,10 @@ export class MatrixRestClient {
|
|||||||
private onMessageCallback: ((message: ChatMessage) => void) | null = null;
|
private onMessageCallback: ((message: ChatMessage) => void) | null = null;
|
||||||
private isSyncing: boolean = false;
|
private isSyncing: boolean = false;
|
||||||
|
|
||||||
|
// Presence & Typing callbacks
|
||||||
|
onPresence?: (event: PresenceEvent) => void;
|
||||||
|
onTyping?: (roomId: string, userIds: string[]) => void;
|
||||||
|
|
||||||
constructor(config: MatrixClientConfig) {
|
constructor(config: MatrixClientConfig) {
|
||||||
this.baseUrl = config.baseUrl;
|
this.baseUrl = config.baseUrl;
|
||||||
this.accessToken = config.accessToken;
|
this.accessToken = config.accessToken;
|
||||||
@@ -214,9 +244,15 @@ export class MatrixRestClient {
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
timeout: '30000',
|
timeout: '30000',
|
||||||
filter: JSON.stringify({
|
filter: JSON.stringify({
|
||||||
|
presence: {
|
||||||
|
types: ['m.presence']
|
||||||
|
},
|
||||||
room: {
|
room: {
|
||||||
timeline: { limit: 50 },
|
timeline: { limit: 50 },
|
||||||
state: { lazy_load_members: true }
|
state: { lazy_load_members: true },
|
||||||
|
ephemeral: {
|
||||||
|
types: ['m.typing', 'm.receipt']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -243,9 +279,20 @@ export class MatrixRestClient {
|
|||||||
const data: MatrixSyncResponse = await res.json();
|
const data: MatrixSyncResponse = await res.json();
|
||||||
this.syncToken = data.next_batch;
|
this.syncToken = data.next_batch;
|
||||||
|
|
||||||
// Process new messages
|
// Process presence events
|
||||||
|
if (data.presence?.events && this.onPresence) {
|
||||||
|
for (const event of data.presence.events) {
|
||||||
|
if (event.type === 'm.presence') {
|
||||||
|
this.onPresence(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process room events
|
||||||
if (data.rooms?.join && this.roomId) {
|
if (data.rooms?.join && this.roomId) {
|
||||||
const roomData = data.rooms.join[this.roomId];
|
const roomData = data.rooms.join[this.roomId];
|
||||||
|
|
||||||
|
// Process new messages
|
||||||
if (roomData?.timeline?.events) {
|
if (roomData?.timeline?.events) {
|
||||||
for (const event of roomData.timeline.events) {
|
for (const event of roomData.timeline.events) {
|
||||||
if (event.type === 'm.room.message' && event.content?.body) {
|
if (event.type === 'm.room.message' && event.content?.body) {
|
||||||
@@ -254,6 +301,16 @@ export class MatrixRestClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process typing events
|
||||||
|
if (roomData?.ephemeral?.events && this.onTyping) {
|
||||||
|
for (const event of roomData.ephemeral.events) {
|
||||||
|
if (event.type === 'm.typing') {
|
||||||
|
const typingContent = event.content as TypingContent;
|
||||||
|
this.onTyping(this.roomId, typingContent.user_ids || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
@@ -267,6 +324,27 @@ export class MatrixRestClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send typing notification
|
||||||
|
*/
|
||||||
|
async sendTyping(roomId: string, typing: boolean, timeout: number = 30000): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(
|
||||||
|
`${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/typing/${encodeURIComponent(this.userId)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
typing,
|
||||||
|
timeout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send typing notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map Matrix event to ChatMessage
|
* Map Matrix event to ChatMessage
|
||||||
*/
|
*/
|
||||||
|
|||||||
342
docs/matrix/MATRIX_PRESENCE_TYPING_SPEC.md
Normal file
342
docs/matrix/MATRIX_PRESENCE_TYPING_SPEC.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# MATRIX PRESENCE & TYPING — DAARION.city
|
||||||
|
|
||||||
|
Version: 1.0.0
|
||||||
|
|
||||||
|
## 0. PURPOSE
|
||||||
|
|
||||||
|
Додати у Matrix-чат DAARION (сторінка `/city/[slug]`) базові реальні індикатори:
|
||||||
|
|
||||||
|
- хто **онлайн** у кімнаті,
|
||||||
|
- хто **друкує** зараз (typing).
|
||||||
|
|
||||||
|
Це робиться поверх уже працюючого Matrix Chat Client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MATRIX EVENTS
|
||||||
|
|
||||||
|
Матриця дає 2 типи відповідних подій (через `/sync`):
|
||||||
|
|
||||||
|
### 1.1. Presence events (`m.presence`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "m.presence",
|
||||||
|
"sender": "@user:daarion.space",
|
||||||
|
"content": {
|
||||||
|
"presence": "online", // "online" | "offline" | "unavailable"
|
||||||
|
"last_active_ago": 0,
|
||||||
|
"currently_active": true,
|
||||||
|
"status_msg": "Working..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2. Typing events (`m.typing`)
|
||||||
|
|
||||||
|
В `rooms.join[roomId].ephemeral.events`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "m.typing",
|
||||||
|
"content": {
|
||||||
|
"user_ids": ["@user1:daarion.space", "@user2:daarion.space"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. FRONTEND ARCHITECTURE
|
||||||
|
|
||||||
|
### Existing Components:
|
||||||
|
|
||||||
|
- `lib/matrix-client.ts` — `MatrixRestClient`
|
||||||
|
- `MatrixChatRoom` — працює з повідомленнями та статусом підключення
|
||||||
|
|
||||||
|
### New Additions:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MatrixChatRoom Component │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Header: "General · 5 online" │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Messages Area │ │
|
||||||
|
│ │ [message 1] │ │
|
||||||
|
│ │ [message 2] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Typing: "User abc друкує..." │ │
|
||||||
|
│ │ [Input field] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MATRIX CLIENT: SYNC LOOP
|
||||||
|
|
||||||
|
### 3.1. Sync Filter
|
||||||
|
|
||||||
|
При виклику `/sync` використовуємо filter:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"presence": {
|
||||||
|
"types": ["m.presence"]
|
||||||
|
},
|
||||||
|
"room": {
|
||||||
|
"timeline": {
|
||||||
|
"limit": 50
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"lazy_load_members": true
|
||||||
|
},
|
||||||
|
"ephemeral": {
|
||||||
|
"types": ["m.typing", "m.receipt"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. MatrixRestClient Extensions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PresenceEvent {
|
||||||
|
type: 'm.presence';
|
||||||
|
sender: string;
|
||||||
|
content: {
|
||||||
|
presence: 'online' | 'offline' | 'unavailable';
|
||||||
|
last_active_ago?: number;
|
||||||
|
currently_active?: boolean;
|
||||||
|
status_msg?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypingEvent {
|
||||||
|
type: 'm.typing';
|
||||||
|
content: {
|
||||||
|
user_ids: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatrixRestClient {
|
||||||
|
// Callbacks
|
||||||
|
onPresence?: (event: PresenceEvent) => void;
|
||||||
|
onTyping?: (roomId: string, userIds: string[]) => void;
|
||||||
|
|
||||||
|
// Enhanced sync loop
|
||||||
|
private async syncLoop(): Promise<void> {
|
||||||
|
while (this.isSyncing) {
|
||||||
|
const res = await this.sync(this.syncToken);
|
||||||
|
this.syncToken = res.next_batch;
|
||||||
|
|
||||||
|
// Process presence events
|
||||||
|
if (res.presence?.events) {
|
||||||
|
for (const event of res.presence.events) {
|
||||||
|
if (event.type === 'm.presence') {
|
||||||
|
this.onPresence?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process typing events
|
||||||
|
if (res.rooms?.join && this.roomId) {
|
||||||
|
const roomData = res.rooms.join[this.roomId];
|
||||||
|
if (roomData?.ephemeral?.events) {
|
||||||
|
for (const event of roomData.ephemeral.events) {
|
||||||
|
if (event.type === 'm.typing') {
|
||||||
|
this.onTyping?.(this.roomId, event.content.user_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send typing notification
|
||||||
|
async sendTyping(roomId: string, typing: boolean, timeout?: number): Promise<void> {
|
||||||
|
await fetch(
|
||||||
|
`${this.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/typing/${encodeURIComponent(this.userId)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
typing,
|
||||||
|
timeout: timeout || 30000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MATRIXCHATROOM INTEGRATION
|
||||||
|
|
||||||
|
### 4.1. State
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Online users in room
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState<Map<string, 'online' | 'offline' | 'unavailable'>>(new Map());
|
||||||
|
|
||||||
|
// Users currently typing
|
||||||
|
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2. Callbacks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!matrixClient.current) return;
|
||||||
|
|
||||||
|
// Presence handler
|
||||||
|
matrixClient.current.onPresence = (event) => {
|
||||||
|
if (!event.sender || !event.content?.presence) return;
|
||||||
|
|
||||||
|
setOnlineUsers(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(event.sender, event.content.presence);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typing handler
|
||||||
|
matrixClient.current.onTyping = (roomId, userIds) => {
|
||||||
|
if (roomId !== bootstrap?.matrix_room_id) return;
|
||||||
|
|
||||||
|
// Filter out current user
|
||||||
|
const others = userIds.filter(id => id !== bootstrap?.matrix_user_id);
|
||||||
|
setTypingUsers(new Set(others));
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (matrixClient.current) {
|
||||||
|
matrixClient.current.onPresence = undefined;
|
||||||
|
matrixClient.current.onTyping = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [bootstrap]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3. Send Typing Notification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// When user starts typing
|
||||||
|
const handleInputChange = useCallback(() => {
|
||||||
|
if (matrixClient.current && bootstrap) {
|
||||||
|
matrixClient.current.sendTyping(bootstrap.matrix_room_id, true);
|
||||||
|
}
|
||||||
|
}, [bootstrap]);
|
||||||
|
|
||||||
|
// When user stops typing (debounced)
|
||||||
|
const handleInputBlur = useCallback(() => {
|
||||||
|
if (matrixClient.current && bootstrap) {
|
||||||
|
matrixClient.current.sendTyping(bootstrap.matrix_room_id, false);
|
||||||
|
}
|
||||||
|
}, [bootstrap]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI DISPLAY
|
||||||
|
|
||||||
|
### 5.1. Header (Room Info)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white font-medium">{room.name}</span>
|
||||||
|
<span className="text-slate-400">·</span>
|
||||||
|
<span className="text-emerald-400 text-sm">
|
||||||
|
{onlineCount} online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `onlineCount`:
|
||||||
|
```typescript
|
||||||
|
const onlineCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
onlineUsers.forEach((status, userId) => {
|
||||||
|
if (status === 'online' || status === 'unavailable') {
|
||||||
|
// Optionally exclude current user
|
||||||
|
if (userId !== bootstrap?.matrix_user_id) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}, [onlineUsers, bootstrap]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2. Typing Indicator
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{typingUsers.size > 0 && (
|
||||||
|
<div className="px-4 py-1 text-sm text-slate-400 animate-pulse">
|
||||||
|
{typingUsers.size === 1
|
||||||
|
? `${formatUserName(Array.from(typingUsers)[0])} друкує...`
|
||||||
|
: 'Декілька учасників друкують...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper function:
|
||||||
|
```typescript
|
||||||
|
function formatUserName(userId: string): string {
|
||||||
|
// @daarion_abc123:daarion.space -> User abc123
|
||||||
|
return userId
|
||||||
|
.split(':')[0]
|
||||||
|
.replace('@daarion_', 'User ')
|
||||||
|
.replace('@', '');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LIMITATIONS / MVP
|
||||||
|
|
||||||
|
- ✅ Presence/typing працює тільки в **активній кімнаті** (`/city/[slug]`)
|
||||||
|
- ❌ Не кешуємо статуси між сесіями
|
||||||
|
- ❌ Не показуємо, хто саме онлайн у списку кімнат
|
||||||
|
- ❌ Не показуємо read receipts / last seen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API SUMMARY
|
||||||
|
|
||||||
|
### Matrix Client-Server API
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/_matrix/client/v3/sync` | Get presence + typing events |
|
||||||
|
| PUT | `/_matrix/client/v3/rooms/{roomId}/typing/{userId}` | Send typing notification |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ROADMAP (далі)
|
||||||
|
|
||||||
|
Після цієї фази:
|
||||||
|
|
||||||
|
1. **Room-level activity:**
|
||||||
|
- агрегація онлайн/активності на рівні `/city` списку.
|
||||||
|
|
||||||
|
2. **Read receipts / last read marker.**
|
||||||
|
|
||||||
|
3. **PWA/Mobile presence:**
|
||||||
|
- збереження останнього статусу офлайн,
|
||||||
|
- push при нових повідомленнях у кімнатах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. ACCEPTANCE CRITERIA
|
||||||
|
|
||||||
|
- [ ] Sync loop обробляє `m.presence` та `m.typing` події
|
||||||
|
- [ ] Header показує кількість online користувачів
|
||||||
|
- [ ] Typing indicator показує хто друкує
|
||||||
|
- [ ] Користувач може надсилати typing notification
|
||||||
|
- [ ] При виході з кімнати callbacks очищуються
|
||||||
|
|
||||||
Reference in New Issue
Block a user