# 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:** ```json { "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: ```json { "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:** ```json { "type": "get_deleted_since", "request_id": "uuid", "conversation_id": "conv-uuid", "since": "2026-02-15T22:15:45" } ``` **Response:** ```json { "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: ```json { "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í). ```swift // 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 ```swift 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 ```swift 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).