initial commit
This commit is contained in:
239
ios_client/incremental_sync_changes.md
Normal file
239
ios_client/incremental_sync_changes.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user