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 screated_at > after_ts- Pokud
after_tschybí nebo je null, chová se jako dřív (vrátí posledníchlimitzprá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_readnotifikaci. 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_reactednotifikaci v reálném čase. - pins — stejně jako reactions.
message_pinned/message_unpinnednotifikace. - 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).