1/* 2 * Reiner In-Memory-State: Partnerliste, Konversationen, Unread-Zaehler. 3 * Keine WebSocket-/DOM-Kenntnis - nur Datenhaltung + kleine Ableitungen. 4 * Nutzt "partnerId" als Konversations-Schluessel; siehe 5 * docs/chat-panel-integration.md ("Protokoll-Migration") fuer den Hinweis, 6 * das nicht an vielen Stellen fest zu verdrahten, falls Phase 6 spaeter auf 7 * ein conversationId-Modell umstellt. 8 */ 9 10export class ChatState { 11 partners = new Map(); // partnerId -> partner-Objekt aus GET /chat/partners 12 messages = new Map(); // partnerId -> Array von Message-Objekten (aufsteigend nach id) 13 typing = new Map(); // partnerId -> boolean 14 // Alle gerade geoeffneten Konversationen (nicht nur eine "aktive") - noetig 15 // seit mehrere Konversationsfenster gleichzeitig offen sein koennen (siehe 16 // docs/chat-panel-integration.md, Chat-Panel-Ansichten Modus B/C). 17 openPartnerIds = new Set(); 18 // Unterscheidet "noch nie geladen" von "geladen und tatsaechlich leer" - 19 // chat_panel_controller.js#renderPartners() zeigt sonst faelschlich den 20 // Leerzustand (Plus-Button, "Noch keine Unterhaltungen") waehrend des 21 // allerersten Ladens an, obwohl evtl. laengst Kontakte existieren (Nutzer- 22 // Fund 2026-09-16). false bis zum ersten setPartners()-Aufruf, danach 23 // dauerhaft true (auch bei einer tatsaechlich leeren Liste). 24 partnersLoaded = false; 25 // Server liefert die Partnerliste jetzt seitenweise, sortiert nach `sortId` 26 // absteigend (docs/chat-todo.md #6.1) - true, sobald die zuletzt geladene 27 // Seite keine weitere Seite mehr ankuendigt (hasMore: false). 28 partnersExhausted = false; 29 // Aktiver Suchbegriff der Partnerlisten-Suche, '' = kein Filter. Wird von 30 // loadMorePartners() mitgeschickt, damit Nachladen waehrend einer aktiven 31 // Suche dieselbe gefilterte Menge fortsetzt statt auf alle Partner zurueckzufallen. 32 partnersSearch = ''; 33 // Archivierte Partner (docs/chat-todo.md §39) - getrennt von der regulaeren Liste, die sie 34 // serverseitig gar nicht enthaelt. archivedCount kommt mit der ersten Seite der regulaeren 35 // Liste; geladen werden die archivierten Partner erst beim Aufklappen des Abschnitts. 36 archivedPartners = new Map(); 37 archivedCount = 0; 38 archivedExpanded = false; 39 // Tags pro Konversation (docs/chat-todo.md §43): alle Tags des Nutzers ({id, name, count}, 40 // alphabetisch) und die gerade im Suchfeld gewaehlten (IDs, UND-verknuepft). Wie 41 // partnersSearch wird der Filter beim Nachladen und Neuladen mitgeschickt. 42 tags = []; 43 partnersTagFilter = []; 44 45 setPartners(list) { 46 this.partners.clear(); 47 for (const partner of list) { 48 this.partners.set(partner.id, partner); 49 } 50 this.partnersLoaded = true; 51 } 52 53 // Ersetzt die komplette Partnerliste durch eine frisch geladene erste Seite 54 // (initiales Laden, Full-Refresh-Trigger wie 'newfriend'/'partnerschanged', 55 // oder eine neue Suche) - Server liefert bereits sortiert, kein Re-Sort noetig. 56 resetPartners(list, hasMore) { 57 this.setPartners(list); 58 this.partnersExhausted = !hasMore; 59 } 60 61 // Haengt eine nachgeladene weitere Seite ans Ende an (Map-Einfuegereihenfolge 62 // = Serverreihenfolge). Pendant zu appendMessages() bei den Nachrichten, nur 63 // ohne Dedupe-Sort, da die Partnerliste nicht nach einer stabilen numerischen 64 // id, sondern nach dem vom Server gelieferten sortId sortiert ist. 65 appendPartnerPage(list, hasMore) { 66 for (const partner of list) { 67 this.partners.set(partner.id, partner); 68 } 69 this.partnersExhausted = !hasMore; 70 } 71 72 // Cursor fuer loadMorePartners(): sortId/id des zuletzt eingefuegten (in 73 // Serverreihenfolge letzten) Partners, oder null bei leerer Liste. Pendant 74 // zu lowestMessageId(), aber aus der Map abgeleitet statt aus einem sortierten 75 // Array, da die Partnerliste keine gesonderte Array-Repraesentation haelt. 76 lowestPartnerCursor() { 77 let last = null; 78 for (const partner of this.partners.values()) { 79 last = partner; 80 } 81 return last ? { sortId: last.sortId ?? 0, partnerId: last.id } : null; 82 } 83 84 getPartner(partnerId) { 85 return this.partners.get(partnerId) ?? this.archivedPartners.get(partnerId); 86 } 87 88 setArchivedPartners(list) { 89 this.archivedPartners.clear(); 90 for (const partner of list) { 91 this.archivedPartners.set(partner.id, partner); 92 } 93 } 94 95 isArchived(partnerId) { 96 return this.archivedPartners.has(partnerId); 97 } 98 99 // Stand aus Endpunkt oder 'chatpartnersettings'-Event uebernehmen. Liefert true, wenn sich 100 // dabei Anheften/Archivieren geaendert hat - dann stimmt die Reihenfolge bzw. Aufteilung der 101 // Listen nicht mehr und der Aufrufer laedt neu. 102 applyPartnerSettings(partnerId, settings) { 103 const partner = this.getPartner(partnerId);
104 if (!partner || !settings) { 105 return true; 106 } 107 const listChanged = partner.pinned !== settings.pinned || partner.archived !== settings.archived; 108 partner.muted_until = settings.muted_until; 109 partner.muted_forever = settings.muted_forever; 110 partner.pinned = settings.pinned; 111 partner.archived = settings.archived; 112 return listChanged; 113 } 114 115 // Stumm, solange muted_until (Unix-Sekunden) in der Zukunft liegt - laeuft von selbst ab. 116 isMuted(partnerId) { 117 const until = this.getPartner(partnerId)?.muted_until; 118 return typeof until === 'number' && until * 1000 > Date.now(); 119 } 120 121 addPartner(partner) { 122 if (!this.partners.has(partner.id)) { 123 this.partners.set(partner.id, partner); 124 } 125 return this.partners.get(partner.id); 126 } 127 128 patchPartnerStatus(partnerId, status) { 129 const partner = this.partners.get(partnerId); 130 if (partner) { 131 partner.status = status; 132 } 133 } 134 135 incrementUnread(partnerId) { 136 const partner = this.partners.get(partnerId); 137 if (partner) { 138 partner.unread = (partner.unread ?? 0) + 1; 139 } 140 } 141 142 get totalUnread() { 143 let sum = 0; 144 for (const partner of this.partners.values()) { 145 sum += partner.unread ?? 0; 146 } 147 return sum; 148 } 149 150 getMessages(partnerId) { 151 return this.messages.get(partnerId) ?? []; 152 } 153 154 setMessages(partnerId, list) { 155 this.messages.set(partnerId, [...list].sort(compareMessages)); 156 } 157 158 prependHistory(partnerId, olderMessages) { 159 const existing = this.getMessages(partnerId); 160 const merged = [...olderMessages, ...existing]; 161 this.setMessages(partnerId, dedupeById(merged)); 162 } 163 164 appendMessages(partnerId, newMessages) { 165 const existing = this.getMessages(partnerId); 166 const merged = [...existing, ...newMessages]; 167 this.setMessages(partnerId, dedupeById(merged)); 168 } 169 170 replaceOptimisticMessage(partnerId, guid, serverMessage) { 171 const list = this.getMessages(partnerId); 172 const idx = list.findIndex((m) => m.guid === guid); 173 if (idx !== -1) { 174 list[idx] = { ...list[idx], ...serverMessage }; 175 } 176 } 177 178 removeOptimisticMessage(partnerId, guid) { 179 const list = this.getMessages(partnerId); 180 this.messages.set(partnerId, list.filter((m) => m.guid !== guid)); 181 } 182 183 highestMessageId(partnerId) { 184 const list = this.getMessages(partnerId); 185 return list.length ? list[list.length - 1].id : -1; 186 } 187 188 lowestMessageId(partnerId) { 189 const list = this.getMessages(partnerId); 190 return list.length ? list[0].id : -1; 191 } 192 193 // Eigene, an partnerId gesendete Nachrichten als von ihm gelesen markieren 194 // (id <= seenUntil) - ausgeloest durch dessen 'seen'-WS-Envelope (siehe 195 // chat_client.js#handleMessage(), Fall 'direct'/'seen'). Nur 'out'- 196 // Nachrichten betroffen, das ist der einzige Zweck des Lesestatus 197 // (chat_conversation_controller.js#renderSeenIndicator()). 198 markSeenUntil(partnerId, seenUntil) { 199 for (const message of this.getMessages(partnerId)) { 200 if (message.direction === 'out' && message.id !== undefined && message.id <= seenUntil) { 201 message.seen = 2; 202 } 203 } 204 } 205 206 setTyping(partnerId, isTyping) { 207 this.typing.set(partnerId, isTyping); 208 } 209 210 isTyping(partnerId) { 211 return this.typing.get(partnerId) === true; 212 } 213 214 openPartner(partnerId) { 215 this.openPartnerIds.add(partnerId); 216 } 217 218 closePartner(partnerId) { 219 this.openPartnerIds.delete(partnerId); 220 } 221 222 isOpen(partnerId) { 223 return this.openPartnerIds.has(partnerId); 224 } 225} 226 227function dedupeById(list) { 228 const seen = new Set(); 229 const result = []; 230 for (const item of list) { 231 if (item.id === undefined || !seen.has(item.id)) { 232 if (item.id !== undefined) { 233 seen.add(item.id); 234 } 235 result.push(item); 236 } 237 } 238 return result.sort(compareMessages); 239} 240 241// Nachrichten ohne (noch) bestaetigte Server-id sind optimistisch angehaengte, 242// gerade erst selbst versendete Nachrichten (siehe replaceOptimisticMessage) - 243// muessen ans Ende sortiert werden, nicht per "?? 0" an den Anfang (kleiner 244// als jede echte id). Untereinander per stabilem Sort (result 0) in
245// Einfuege-, also Versandreihenfolge belassen. 246function compareMessages(a, b) { 247 if (a.id === undefined && b.id === undefined) return 0; 248 if (a.id === undefined) return 1; 249 if (b.id === undefined) return -1; 250 return a.id - b.id; 251}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.