Files
Kecalek_python/ios_client/incremental_sync_changes.md
2026-03-11 16:54:14 +01:00

7.5 KiB

iOS Client — Inkrementální sync zpráv

Problém

Klient při každém otevření konverzace posílá get_messages a server vrací 50 zpráv (šifrované bloby + metadata). I když klient 49 z nich už má. Zbytečný přenos dat a zátěž serveru.

Řešení

Server už podporuje parametr after_ts v get_messages. Klient si pamatuje timestamp poslední zprávy a posílá jen dotaz na novější.


Protokol — co posílat serveru

get_messages — nový volitelný parametr after_ts

Request:

{
    "type": "get_messages",
    "request_id": "uuid",
    "conversation_id": "conv-uuid",
    "limit": 50,
    "offset": 0,
    "after_ts": "2026-02-15T22:15:45"
}
  • after_ts (string, ISO 8601, volitelný) — server vrátí jen zprávy s created_at > after_ts
  • Pokud after_ts chybí nebo je null, chová se jako dřív (vrátí posledních limit zpráv)

Response — beze změny, jen méně zpráv:

{
    "type": "get_messages",
    "status": "ok",
    "data": {
        "messages": [...],
        "total_count": 123
    }
}

get_deleted_since — sync smazaných zpráv

Po inkrementálním fetchi je nutné zjistit co bylo smazáno od posledního syncu.

Request:

{
    "type": "get_deleted_since",
    "request_id": "uuid",
    "conversation_id": "conv-uuid",
    "since": "2026-02-15T22:15:45"
}

Response:

{
    "type": "get_deleted_since",
    "status": "ok",
    "data": {
        "message_ids": ["msg-uuid-1", "msg-uuid-2"]
    }
}

mark_read — optimalizace

Request — beze změny, jen posílat méně ID:

{
    "type": "mark_read",
    "request_id": "uuid",
    "conversation_id": "conv-uuid",
    "message_ids": ["only-unread-msg-id-1"]
}

Filtrovat na klientovi: jen zprávy kde sender_id != myId a myId není v read_by.


Implementace na iOS klientovi

1. Lokální cache zpráv

Ukládat dešifrované zprávy na disk per konverzace. Klíč = message_id, hodnota = dešifrovaný payload (bez read_by — ten se mění).

// MessageCache.swift nebo rozšíření ChatClient

/// Uložit zprávu do lokální cache
func cacheMessage(convId: String, msgId: String, payload: [String: Any])

/// Načíst cache pro konverzaci → [msgId: payload]
func loadCache(convId: String) -> [String: [String: Any]]

/// Smazat zprávu z cache
func removeCachedMessage(convId: String, msgId: String)

Formát na disku: JSON soubor v app sandbox, šifrovaný identity key (stejně jako Python klient).

2. Logika v getMessages()

1. Načíst lokální cache pro conv_id
2. Pokud cache je neprázdná A offset == 0:
   a. Najít nejnovější created_at v cache → after_ts
   b. Poslat get_messages s after_ts (server vrátí jen nové)
   c. Dešifrovat nové zprávy, přidat do cache
   d. Poslat get_deleted_since s after_ts → smazat z cache
   e. Sestavit výsledek z cache (seřadit, vzít posledních limit)
3. Pokud cache je prázdná NEBO offset > 0:
   a. Plný fetch jako dřív (bez after_ts)
   b. Dešifrovat, uložit do cache
   c. Vrátit
4. mark_read: filtrovat jen sender_id != myId a myId not in read_by

3. Pseudokód

func getMessages(convId: String, limit: Int = 50, offset: Int = 0) async -> [Message] {
    var cache = loadCache(convId: convId)
    let myId = userId ?? ""

    // Rozhodnout: inkrementální vs plný fetch
    var afterTs: String? = nil
    if !cache.isEmpty && offset == 0 {
        afterTs = cache.values
            .compactMap { $0["created_at"] as? String }
            .filter { !($0.isEmpty) }
            .max()
    }

    // Fetch ze serveru
    var params: [String: Any] = [
        "conversation_id": convId,
        "limit": limit,
        "offset": offset,
    ]
    if let ts = afterTs {
        params["after_ts"] = ts
    }
    let resp = await sendAndReceive(type: "get_messages", params: params)

    guard resp.string(for: "status") == "ok",
          let data = resp.dict(for: "data"),
          let rawMessages = data["messages"] as? [[String: Any]] else {
        // Offline fallback — vrátit z cache
        if !cache.isEmpty && offset == 0 {
            return buildFromCache(cache, limit: limit)
        }
        return []
    }

    // Dešifrovat nové zprávy (existující logika)
    let newMessages = decryptRawMessages(rawMessages, cache: &cache, convId: convId)

    // mark_read jen pro nepřečtené
    let unreadIds = rawMessages.filter { msg in
        let senderId = msg["sender_id"] as? String ?? ""
        if senderId == myId { return false }
        let readBy = msg["read_by"] as? [[String: Any]] ?? []
        return !readBy.contains { ($0["user_id"] as? String) == myId }
    }.compactMap { $0["message_id"] as? String }

    if !unreadIds.isEmpty {
        await markRead(convId: convId, messageIds: unreadIds)
    }

    if afterTs != nil {
        // Inkrementální: sync smazaných
        let delResp = await sendAndReceive(type: "get_deleted_since", params: [
            "conversation_id": convId,
            "since": afterTs!,
        ])
        if let delData = delResp.dict(for: "data"),
           let delIds = delData["message_ids"] as? [String] {
            for id in delIds {
                cache.removeValue(forKey: id)
                removeCachedMessage(convId: convId, msgId: id)
            }
        }
        return buildFromCache(cache, limit: limit)
    }

    return newMessages
}

/// Sestavit seřazený seznam z cache
func buildFromCache(_ cache: [String: [String: Any]], limit: Int) -> [Message] {
    var messages: [Message] = []
    for (msgId, payload) in cache {
        guard payload["_control"] == nil else { continue }
        // Vytvořit Message z payload...
        messages.append(messageFromPayload(msgId: msgId, payload: payload))
    }
    messages.sort { $0.createdAt < $1.createdAt }
    if messages.count > limit {
        messages = Array(messages.suffix(limit))
    }
    return messages
}

4. Co se změní v praxi

Situace Dřív Teď
Otevření konverzace kde jsem byl před 5 min Server vrátí 50 zpráv (vše) Server vrátí 0-2 nové zprávy
Otevření konverzace poprvé Server vrátí 50 zpráv Stejné (plný fetch)
Load older (scroll nahoru) Server vrátí 50 starších Stejné (offset > 0, plný fetch)
Po reconnectu Server vrátí 50 zpráv Server vrátí jen zprávy od odpojení
Offline Nic (chyba) Zobrazí cache

5. Metadata (read_by, reactions, pins)

  • read_by — neukládá se do cache (mění se často). Přichází v reálném čase přes messages_read notifikaci. Po reconnectu může být chvilku stale — přijatelné.
  • reactions — server je vrací u každé zprávy. V cache se ukládají. Aktualizace přes message_reacted notifikaci v reálném čase.
  • pins — stejně jako reactions. message_pinned/message_unpinned notifikace.
  • Po inkrementálním fetchi jsou metadata aktuální jen pro NOVÉ zprávy. Starší mají stav z cache + real-time notifikací. Při plném fetchi (scroll nahoru / první load) jsou vždy aktuální.

6. ChatViewModel.loadMessages — úprava

func loadMessages(convId: String, chatClient: ChatClient) async {
    isLoading = true
    messages = await chatClient.getMessages(convId: convId, limit: 50)
    isLoading = false
    // mark_read se teď řeší uvnitř getMessages — tady nic
    updatePinnedBanner()
}

mark_read volání se přesune z ViewModelu do getMessages() v ChatClientu (tam kde má přístup k read_by z response).