Files
microdao-daarion/docs/matrix/MATRIX_PRESENCE_TYPING_SPEC.md
Apple 573afec40f 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
2025-11-26 13:32:51 -08:00

9.5 KiB
Raw Blame History

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)

{
  "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:

{
  "type": "m.typing",
  "content": {
    "user_ids": ["@user1:daarion.space", "@user2:daarion.space"]
  }
}

2. FRONTEND ARCHITECTURE

Existing Components:

  • lib/matrix-client.tsMatrixRestClient
  • 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:

{
  "presence": {
    "types": ["m.presence"]
  },
  "room": {
    "timeline": {
      "limit": 50
    },
    "state": {
      "lazy_load_members": true
    },
    "ephemeral": {
      "types": ["m.typing", "m.receipt"]
    }
  }
}

3.2. MatrixRestClient Extensions

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

// 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

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

// 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)

<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:

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

{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:

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 очищуються