1import { Controller } from '@hotwired/stimulus'; 2import { VirtualMessageList } from '../core/virtual_message_list.js'; 3import { sendFriendRequest } from '../core/api.js'; 4import { EMOJI_LIST } from '../core/emoji_list.js'; 5import { getEnterMode, ENTER_MODE_CHANGED_EVENT } from '../core/chat_settings.js'; 6import { iconSpriteUrl } from '../../core/icon_sprite.js'; 7import { deletedAvatarUrl, partnerDisplayName } from '../core/partner_display.js'; 8import { TAG_EDITING_ENABLED } from '../core/chat_features.js'; 9 10/* 11 * Eine einzelne Konversation (Nachrichtenliste + Eingabeformular) - eigener 12 * Controller mit eigenem Scope statt Targets direkt am chat-panel-Controller, 13 * damit mehrere Instanzen gleichzeitig auf der Seite existieren koennen 14 * (Chat-Panel-Ansichten Modus B/C, siehe docs/chat-panel-integration.md). 15 * Greift ausschliesslich ueber ein Stimulus-Outlet auf die ChatClient-Instanz 16 * des Panel-Controllers zu (gleiches Muster wie chat_call_controller.js), 17 * kein eigener WebSocket-/State-Zugriff hier. Instanzen werden von 18 * chat_panel_controller.js per <template>-Klon erzeugt und wiederverwendet 19 * (Partnerwechsel = partnerIdValue aendern, siehe partnerIdValueChanged()). 20 */ 21 22// partnerId -> ausstehender close-Timer. Modul-Ebene statt Instanzeigenschaft, 23// da Turbos Pluck-and-Reinsert-Mechanismus (siehe chat_panel_controller.js) 24// disconnect() auf der ALTEN und den naechsten open() auf einer NEUEN 25// Controller-Instanz feuert - ohne diese Bruecke wuerde jede Turbo-Navigation 26// die Konversation kurz "schliessen", bevor sie sofort wieder geoeffnet wird. 27const pendingCloseTimers = new Map(); 28 29// partnerId -> kompletter Nachrichtenverlauf bereits geladen (leere Antwort 30// von loadOlderHistory()). Modul-Ebene aus demselben Grund wie 31// pendingCloseTimers - die Controller-Instanz wird pro Partnerwechsel 32// wiederverwendet. 33const exhaustedHistoryPartners = new Set(); 34 35// Serverseitiges Limit (app.chat.audio.max_duration_seconds, aktueller 36// Default in sites/*/config/cng.yaml) - der Client bricht vorsorglich hier 37// ab, massgeblich bleibt aber ChatController::getAttachmentData(). 38const MAX_VOICE_DURATION_SECONDS = 300; 39 40// Hoechstens so viele Tags direkt im Kebab-Menue (docs/chat-todo.md §43), der Rest ueber den 41// Tag-Dialog - zugeordnete Tags werden aber immer alle gezeigt. 42const MENU_TAG_LIMIT = 6; 43 44// docs/chat-todo.md #28: Schwelle fuer die .chat-message-list--wide-Klasse 45// (chat-panel.css, steuert die Sprechblasen-Maximalbreite in zwei festen 46// Stufen statt kontinuierlich mit der Containerbreite) - MUSS mit dem dort 47// hart codierten 450px-Wert uebereinstimmen. Bewusst als fester Wert 48// gehalten statt aus CSS ausgelesen - erspart einen zusaetzlichen 49// getComputedStyle()-Umweg fuer einen Wert, der sich nicht zur Laufzeit 50// aendert. 51const WIDE_BUBBLE_BREAKPOINT_PX = 450; 52 53// docs/chat-todo.md #24: .chat-input soll ueber die CSS-Basishoehe (74px, 54// ~3 Zeilen, chat-panel.css .chat-input:focus) hinaus weiterwachsen, wenn der 55// Inhalt das braucht - bis maximal 5 Zeilen (~104px bei 18px Zeilenhoehe + 56// Bootstrap-.form-control-Padding, siehe #autoGrowInput() unten), danach 57// scrollt das Feld intern weiter (overflow:auto kommt schon aus der 58// bestehenden CSS-Regel). 59const MAX_INPUT_HEIGHT_PX = 104; 60 61export default class extends Controller { 62 static targets = ['messages', 'input', 'form', 'partnerName', 'partnerAvatar', 'partnerOnlineStatus', 'partnerStatus', 'partnerStatusText', 'callButton', 'blockButton', 'menuDropdown', 'emptyState', 'attachmentInput', 'uploadMenuDropdown', 'emojiMenuDropdown', 'voiceModal', 'voiceModalHint', 'voiceModalRecordBtn', 'voiceModalTimer', 'voiceModalPreview', 'voiceModalActions', 'backUnreadBadge', 'scrollToBottomBtn', 'seenInfoPopup', 'revocationDialog', 'revocationCheckbox', 'friendDialog', 'friendDialogAvatar', 'friendDialogText', 'friendDialogSendBtn', 'friendDialogCloseBtn', 'muteButton', 'muteOptions', 'unmuteButton', 'muteInfo', 'pinLabel', 'archiveLabel', 'archiveIcon', 'partnerTags', 'tagMenuButton', 'tagOptions', 'tagDialog', 'tagDialogList', 'tagDialogInput', 'tagDialogError']; 63 static values = { partnerId: Number }; 64 static outlets = ['chat-panel']; 65 66 #onMessageReceived = null; 67 #onTypingChanged = null; 68 #onUnreadChanged = null; 69 #onMessageSeen = null; 70 #onRevocationRequired = null; 71 #pendingRevocationSend = null; 72 #onFriendRequired = null; 73 #onPartnersUpdated = null; 74 #onTagsUpdated = null; 75 #onMessageSendFailed = null; 76 #onAgeRangeWarning = null; 77 #mediaRecorder = null; 78 #recordingStartedAt = 0; 79 #recordingCancelled = false;
80 #recordedBlob = null; 81 #recordedPreviewUrl = null; 82 #recordingDurationSeconds = 0; 83 #currentUsername = null; 84 #recordingTimerId = null; 85 #isLoadingHistory = false; 86 #profileStatusRequested = false; 87 #resizeObserver = null; 88 // Virtualisiert die Nachrichtenliste (docs/chat-todo.md #24, Szenario E: 89 // 100.000 Nachrichten in einer Konversation liessen den Tab nach ~29s 90 // abstuerzen). Bis 2026-09-24 per Clusterize.js - dessen Annahme 91 // einheitlicher Zeilenhoehen brach an den stark unterschiedlich hohen 92 // Nachrichten-Zeilen (Bilder, Sprachnachrichten, Karten) und liess die 93 // Scrollposition beim Scrollen zurueckspringen (docs/chat-todo.md #28). 94 // Ersetzt durch eine eigene, hoehen-bewusste Virtualisierung 95 // (VirtualMessageList, core/virtual_message_list.js) - misst jede Zeile 96 // beim ersten echten Rendern und cacht die Hoehe dauerhaft, statt sie zu 97 // schaetzen. #partnerListClusterize in chat_panel_controller.js bleibt 98 // unveraendert bei Clusterize (dortige Zeilen sind uniform hoch, kein 99 // Bug). scrollElem === contentElem (dasselbe <ul>), .chat-message-list 100 // bleibt der echte Scroll-Container fuer #isNearBottom()/ 101 // onMessagesScroll()/etc. 102 #virtualList = null; 103 // Nur fuer den rAF-Drossel-Takt der Fenster-Neuberechnung in 104 // onMessagesScroll() (siehe dort) - unabhaengig von #virtualList selbst. 105 #scrollRafPending = false; 106 // Nachtrag zum obigen Fund: Clusterize allein loest nur die DOM-/Layout- 107 // Seite. Ein Nachtest bei 100.000 Basis-Nachrichten zeigte, dass render() 108 // bei JEDER neuen Nachricht weiterhin alle N Zeilen-Strings neu baute 109 // (renderMessage() inkl. toLocaleTimeString()/toLocaleDateString() pro 110 // Nachricht) - 15-22s pro Sendevorgang, Tab-Crash nach 4-5 Sendevorgaengen 111 // in Folge. Die folgenden vier Felder erlauben #render(), fuer den 112 // Normalfall (neue Nachricht kommt an/wird gesendet) nur die NEUEN Zeilen 113 // anzuhaengen statt die komplette Liste erneut zu durchlaufen - siehe 114 // #appendNewMessageRows()/#prependOlderMessageRows() unten. 115 #currentRows = []; 116 #currentRowsPartnerId = null; 117 #renderedCount = 0; 118 #firstRenderedDateKey = null; 119 // guid der zuletzt gerenderten Nachricht (nur bei selbst gesendeten 120 // Nachrichten gesetzt) - erlaubt #isLastMessageOnlyContentChange() zu 121 // erkennen, wenn chat_client.js#sendMessage() dieselbe Nachricht per 122 // ChatState#replaceOptimisticMessage() aktualisiert (optimistisch -> vom 123 // Server bestaetigt), ohne dafuer die komplette Liste neu bauen zu muessen. 124 #lastRenderedGuid = undefined; 125 #typingRowAppended = false; 126 // Ausschliesslich per 'scroll'-Event (onMessagesScroll(), siehe auch 127 // programmatisches Scrollen dort) aktuell gehalten - ein ResizeObserver 128 // aendert scrollTop nicht von sich aus, der zuletzt bekannte Wert 129 // spiegelt also zuverlaessig den Zustand VOR einer Groessenaenderung 130 // wider (siehe connect() unten). 131 #stickToBottom = true; 132 // Experiment (Nutzer-Anfrage): nur touchmove im Eingabefeld blocken (nicht 133 // touchstart - das verhinderte den Fokus/die Tastatur komplett, Nutzer- 134 // Feedback). touchmove blockiert die Scroll-Geste, touchstart/touchend 135 // bleiben unangetastet, damit ein einfaches Antippen (= Fokus, keine 136 // Bewegung) weiter funktioniert. { passive:false } noetig, sonst wird 137 // preventDefault() ignoriert. 138 #onInputTouchMove = (event) => event.preventDefault(); 139 // docs/chat-todo.md #32: Einstellungsdialog ist seit dort PANEL-weit 140 // (chat_panel_controller.js#saveSettings()), nicht mehr pro Konversations- 141 // Instanz - jede offene Instanz (mehrere gleichzeitig moeglich, 142 // Schwebefenster) muss ihren eigenen enterKeyHint trotzdem auffrischen, 143 // sobald irgendwo gespeichert wird, ohne dafuer neu verbinden zu muessen. 144 #onEnterModeChanged = () => this.#applyEnterKeyHint(); 145 146 get chatClient() { 147 return this.chatPanelOutlet.chatClient; 148 } 149 150 // Nachrichtenbereich soll bei JEDER Groessenveraenderung mitscrollen - 151 // .chat-input waechst per CSS bei :focus (chat-panel.css), das 152 // Floating-Fenster kann per Resize-Handle vergroessert/verkleinert 153 // werden, auf Mobile oeffnet/schliesst die Tastatur - all das quetscht/ 154 // dehnt den Nachrichtenbereich per Flexbox, ohne dass render() dafuer
155 // erneut laeuft (Nutzer-Feedback). Nur wenn man ohnehin schon ganz unten 156 // war (#stickToBottom, 15px Toleranz) - sonst wuerde eine 157 // Groessenaenderung waehrend des Lesens aelterer Nachrichten den Nutzer 158 // ungewollt nach unten reissen. 159 connect() { 160 // Neu bei jedem connect() statt einmalig - siehe identische Begruendung 161 // bei #partnerListClusterize in chat_panel_controller.js (transientes 162 // Turbo-Wiederverbinden). onRowsRendered feuert synchron bei jedem 163 // DOM-Update von VirtualMessageList, das tatsaechlich neue Zeilen ins 164 // DOM bringt (initial UND beim Scrollen) - genau dort muessen neu 165 // sichtbar gewordene Sprachnachrichten-Player verdrahtet werden, nicht 166 // nur beim ersten render(). 167 if (this.hasMessagesTarget) { 168 // Vor der Instanziierung setzen (nicht erst im ersten 169 // ResizeObserver-Tick) - vermeidet einen unnoetigen, weil 170 // ergebnislosen ersten handleWidthChange()-Durchlauf direkt nach 171 // dem initialen reset() (siehe #28-Kommentar unten). 172 const initialWide = this.messagesTarget.clientWidth >= WIDE_BUBBLE_BREAKPOINT_PX; 173 this.messagesTarget.classList.toggle('chat-message-list--wide', initialWide); 174 this.#virtualList = new VirtualMessageList(this.messagesTarget, { 175 initialWide, 176 onRowsRendered: (root) => { 177 this.#wireVoicePlayers(root); 178 this.#wireImageReflow(root); 179 }, 180 }); 181 } 182 183 this.#resizeObserver = new ResizeObserver(() => { 184 if (this.#stickToBottom) { 185 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; 186 } 187 // docs/chat-todo.md #28: Sprechblasenbreite haengt seit der 188 // .chat-message-list--wide-Klasse (chat-panel.css) nur noch von 189 // dieser EINEN Stufen-Grenze ab, nicht mehr kontinuierlich von der 190 // exakten Pixelbreite - #handleWidthChange() bekommt deshalb nur 191 // noch das Stufen-Bit statt this.messagesTarget.clientWidth 192 // mitgeteilt und tut waehrend eines laufenden Resizes (z.B. Ziehen 193 // am Schwebefenster-Resize-Handle, feuert dabei sehr viele 194 // Resize-Events) INNERHALB derselben Stufe dadurch gar nichts mehr 195 // - keine Entprellung noetig. 196 const isWide = this.messagesTarget.clientWidth >= WIDE_BUBBLE_BREAKPOINT_PX; 197 this.messagesTarget.classList.toggle('chat-message-list--wide', isWide); 198 this.#virtualList?.handleWidthChange(isWide); 199 }); 200 this.#resizeObserver.observe(this.messagesTarget); 201 // Aufnahme-Vorschau-Player (docs/chat-todo.md #20.9) ist stabiles 202 // Template-Markup, nicht Teil von render() - einmalig hier 203 // verdrahten statt bei jedem Nachrichtenverlauf-Rebuild erneut. 204 if (this.hasVoiceModalTarget) { 205 this.#wireVoicePlayers(this.voiceModalTarget); 206 } 207 this.#applyEnterKeyHint(); 208 window.addEventListener(ENTER_MODE_CHANGED_EVENT, this.#onEnterModeChanged); 209 } 210 211 // app.chat.read_receipts_mode (sites/*/config/cng.yaml): 'off'/'on'/ 212 // 'friends_only', siehe renderSeenIndicator() unten. Fallback 'on' nur 213 // fuer den Fall eines (in Produktion nicht vorgesehenen) leeren Werts - 214 // zeigt dann wie vor Einfuehrung dieses Flags immer den echten Status. 215 get readReceiptsMode() { 216 return (this.hasChatPanelOutlet && this.chatPanelOutlet.readReceiptsModeValue) || 'on'; 217 } 218 219 chatPanelOutletConnected(outlet) { 220 const chatClient = outlet.chatClient; 221 this.#onMessageReceived = (e) => this.onUpdate(e.detail.partnerId, e.detail.forceScroll === true, e.detail.prepended ?? 0); 222 this.#onTypingChanged = (e) => this.onUpdate(e.detail.partnerId); 223 this.#onUnreadChanged = (e) => this.renderUnreadBadge(e.detail.total); 224 // Partner hat eigene, gerade gesendete Nachrichten gelesen (ChatState#markSeenUntil() 225 // aktualisiert message.seen bereits vor diesem Event) - Neu-Rendern zeigt das 226 // Haekchen dann direkt als "gelesen" an, siehe renderSeenIndicator(). 227 this.#onMessageSeen = (e) => this.onUpdate(e.detail.partnerId); 228 // Eigener, instanzgebundener Handler statt ueber chat_panel_controller.js 229 // (Nutzer-Feedback 2026-09-09): eine Konversation kann auch in einem 230 // Floating-Fenster offen sein, der Dialog muss dann DORT erscheinen statt 231 // im Panel. Filter auf partnerId noetig, da chatClient das Event global an 232 // ALLE offenen Konversations-Instanzen verteilt (Sidebar + jedes offene 233 // Floating-Fenster) - ohne den Filter wuerde jede von ihnen den Dialog 234 // zeigen, nicht nur die, aus der tatsaechlich gesendet wurde. 235 this.#onRevocationRequired = (e) => { 236 if (e.detail.partnerId === this.partnerIdValue) this.showRevocationDialog(e.detail); 237 }; 238 // Analog zu #onRevocationRequired (Nutzer-Feedback 2026-09-09): der 239 // Freundschafts-Hinweis bei einem AV-Chat-Versuch ohne Freundschaft lag 240 // bisher als Singleton in chat_panel_controller.js und erschien deshalb 241 // immer in der Sidebar - unsichtbar/unbemerkt, wenn man gerade in einem 242 // Floating-Fenster war ("es passiert nichts"). 243 this.#onFriendRequired = (e) => { 244 if (e.detail.partnerId === this.partnerIdValue) this.show
244FriendDialog(e.detail.partnerId); 245 }; 246 // Nutzer-Feedback (2026-09-09): Online-Status und der "wird geprueft"- 247 // Hinweis in der Kopfzeile wurden bisher nur einmalig beim Oeffnen der 248 // Konversation gesetzt (siehe #renderHeader()) - aenderte sich der 249 // Partner waehrenddessen (Admin schaltet ihn frei, Partner geht online/ 250 // offline), blieb die Anzeige bis zum naechsten Partnerwechsel veraltet 251 // stehen. partners:updated feuert bereits bei jedem Refetch der 252 // Partnerliste (chat_client.js#refreshPartners()) - reicht als Trigger, 253 // ohne dass es dafuer einen eigenen Push braeuchte. 254 this.#onPartnersUpdated = () => { 255 this.#renderHeader(this.partnerIdValue); 256 // Der Zaehler des offenen Gespraechs steckt im Partner (siehe renderUnreadBadge()). 257 this.renderUnreadBadge(this.chatClient.totalUnread); 258 }; 259 // Tag-Namen (docs/chat-todo.md §43) koennen sich auf einem anderen Geraet aendern. 260 this.#onTagsUpdated = () => { 261 this.#renderPartnerTags(this.chatClient.state.getPartner(this.partnerIdValue)); 262 if (this.hasTagDialogTarget && !this.tagDialogTarget.classList.contains('hidden')) this.#renderTagDialogList(); 263 if (this.hasTagOptionsTarget && !this.tagOptionsTarget.classList.contains('hidden')) this.#renderTagOptions(); 264 }; 265 // Eingabefeld wird beim Absenden optimistisch sofort geleert 266 // (submitMessage()) - schlaegt der Versand fehl (Netzwerkfehler, 267 // Server down, z.B. waehrend eines Deploy-Wartungsfensters), soll der 268 // getippte Text nicht verloren gehen. Nur restaurieren, wenn die 269 // Konversation noch offen ist und das Feld seither nicht schon wieder 270 // neu befuellt wurde (sonst wuerde ein bereits neu getippter Text 271 // ueberschrieben). 272 this.#onMessageSendFailed = (e) => this.#restoreFailedMessageText(e.detail); 273 // Age-Range (docs/chat-todo.md #24), 'warning'-Modus: Nachricht wurde 274 // gesendet, aber der Empfaenger hat eine Altersspanne eingestellt, die der 275 // Absender nicht erfuellt - nur ein Hinweis, kein Fehler. 276 this.#onAgeRangeWarning = ({ detail }) => { 277 if (detail.partnerId !== this.partnerIdValue) return; 278 alert('Hinweis: Diese Person hat eine bevorzugte Altersspanne für Kontaktanfragen eingestellt, auÃerhalb derer du liegst. Deine Nachricht wurde trotzdem gesendet.'); 279 }; 280 chatClient.addEventListener('message:received', this.#onMessageReceived); 281 chatClient.addEventListener('typing:changed', this.#onTypingChanged); 282 chatClient.addEventListener('unread:changed', this.#onUnreadChanged); 283 chatClient.addEventListener('message:seen', this.#onMessageSeen); 284 chatClient.addEventListener('revocation-waiver:required', this.#onRevocationRequired); 285 chatClient.addEventListener('call:friend-required', this.#onFriendRequired); 286 chatClient.addEventListener('partners:updated', this.#onPartnersUpdated); 287 chatClient.addEventListener('tags:updated', this.#onTagsUpdated); 288 chatClient.addEventListener('message:send-failed', this.#onMessageSendFailed); 289 chatClient.addEventListener('message:age-range-warning', this.#onAgeRangeWarning); 290 this.renderUnreadBadge(chatClient.totalUnread); 291 this.open(this.partnerIdValue); 292 this.inputTarget.addEventListener('touchmove', this.#onInputTouchMove, { passive: false }); 293 this.inputTarget.addEventListener('input', this.#onInputAutoGrow); 294 } 295 296 chatPanelOutletDisconnected(outlet) { 297 outlet.chatClient?.removeEventListener('message:received', this.#onMessageReceived); 298 outlet.chatClient?.removeEventListener('typing:changed', this.#onTypingChanged); 299 outlet.chatClient?.removeEventListener('unread:changed', this.#onUnreadChanged); 300 outlet.chatClient?.removeEventListener('message:seen', this.#onMessageSeen); 301 outlet.chatClient?.removeEventListener('revocation-waiver:required', this.#onRevocationRequired); 302 outlet.chatClient?.removeEventListener('call:friend-required', this.#onFriendRequired); 303 outlet.chatClient?.removeEventListener('partners:updated', this.#onPartnersUpdated); 304 outlet.chatClient?.removeEventListener('tags:updated', this.#onTagsUpdated); 305 outlet.chatClient?.removeEventListener('message:send-failed', this.#onMessageSendFailed); 306 outlet.chatClient?.removeEventListener('message:age-range-warning', this.#onAgeRangeWarning); 307 this.inputTarget.removeEventListener('touchmove', this.#onInputTouchMove); 308 this.inputTarget.removeEventListener('input', this.#onInputAutoGrow); 309 } 310 311 #onInputAutoGrow = () => this.#autoGrowInput(); 312 313 // Laesst .chat-input ueber die per CSS gesetzte 74px-Basishoehe hinaus 314 // mitwachsen, wenn der Inhalt nicht mehr hineinpasst (docs/chat-todo.md 315 // #24) - deckelt bei MAX_INPUT_HEIGHT_PX (~5 Zeilen). style.height wird 316 // zuerst zurueckgesetzt, damit scrollHeight den tatsaechlich benoetigten 317 // Platz misst (sonst wuerde ein zu hoch gesetzter alter Wert das Feld nie 318 // wieder schrumpfen lassen, wenn Text geloescht wird). 319 #autoGrowInput() { 320 const el = this.inputTarget; 321 el.style.height = ''; 322 if (el.scrollHeight > el.clientHeight) { 323 el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT_PX)}px`; 324 } 325 } 326 327 // Siehe #onMessageSendFailed weiter oben - stellt getippten Text nach 328 // einem fehlgeschlagenen Sendeversuch wieder her. 329 #restoreFailedMessageText({ partnerId, text, error }) { 330 if (partnerId !== this.partnerIdValue) return; 331 if (this.inputTarget.value.trim() !== '') return; 332 this.inputTarget.value = text; 333 this.#autoGrowInput();
334 this.inputTarget.focus(); 335 this.inputTarget.classList.add('chat-input-send-failed'); 336 this.inputTarget.addEventListener('animationend', () => { 337 this.inputTarget.classList.remove('chat-input-send-failed'); 338 }, { once: true }); 339 // Age-Range (docs/chat-todo.md #24), 'full'-Modus: klare Erklaerung statt 340 // nur des generischen roten Pulsierens, das wie ein normaler 341 // Netzwerkfehler aussieht und zu einem sinnlosen Wiederholungsversuch 342 // verleiten wuerde (der Block ist dauerhaft, kein Retry hilft). 343 if (error?.body?.message_code === 'NOT_IN_AGE_RANGE') { 344 alert('Diese Person hat eingestellt, in welcher Altersspanne sie kontaktiert werden möchte - deine Nachricht konnte deshalb nicht gesendet werden.'); 345 } 346 } 347 348 // Ungelesen-Zahl ueber allen Konversationen auf dem Zurueck-Pfeil - siehe 349 // chat_panel_controller.js#renderUnread() fuers Gegenstueck auf der 350 // (waehrend eine Konversation offen ist meist ausgeblendeten) 351 // Panel-Leiste. 352 // Ohne die Ungelesenen des gerade offenen Gespraechs (Nutzer-Vorgabe 2026-10-01) - die sieht 353 // man ja, und eine gerade eintreffende Nachricht liess die Zahl sonst kurz aufblinken, bis sie 354 // als gelesen markiert war. 355 renderUnreadBadge(total) { 356 if (this.hasBackUnreadBadgeTarget) { 357 const current = this.chatClient?.state.getPartner(this.partnerIdValue)?.unread ?? 0; 358 const others = Math.max(0, (total ?? 0) - current); 359 this.backUnreadBadgeTarget.textContent = others > 0 ? String(others) : ''; 360 } 361 } 362 363 // Erstaufruf beim Verbinden macht bereits chatPanelOutletConnected() (dort 364 // ist this.chatClient garantiert verfuegbar) - Stimulus ruft 365 // xValueChanged() aber zusaetzlich einmalig beim Connect mit 366 // oldValue === undefined, das wird hier bewusst uebersprungen. Danach 367 // markiert dieser Callback jeden echten Partnerwechsel derselben Instanz. 368 partnerIdValueChanged(newId, oldId) { 369 if (oldId === undefined) return; 370 if (!this.hasChatPanelOutlet) return; 371 if (oldId !== newId) { 372 this.chatClient.closeConversation(oldId); 373 } 374 this.open(newId); 375 } 376 377 async open(partnerId) { 378 clearTimeout(pendingCloseTimers.get(partnerId)); 379 pendingCloseTimers.delete(partnerId); 380 381 this.#renderHeader(partnerId); 382 this.renderUnreadBadge(this.chatClient.totalUnread); 383 await this.chatClient.openConversation(partnerId); 384 this.render(partnerId); 385 } 386 387 // Ausgelagert aus open() (Nutzer-Feedback 2026-09-09): muss auch erneut 388 // laufen, wenn sich Partnerdaten AENDERN, waehrend die Konversation schon 389 // offen ist (z.B. Online-Status oder in_audit/Profilpruefung-Ende) - vorher 390 // nur einmal beim Oeffnen gesetzt, blieb dann bis zum naechsten 391 // Partnerwechsel stehen. Siehe #onPartnersUpdated unten fuer den Trigger. 392 #renderHeader(partnerId) { 393 const partner = this.chatClient.state.getPartner(partnerId); 394 // "System" (Chat::isSystemAccount(), is_system aus ChatController:: 395 // getPartners()) ist ein Pseudo-Absender fuer serverseitig ausgeloeste 396 // Nachrichten - Antworten wird serverseitig abgelehnt (send()), 397 // Anrufen/Profilaufruf/Blockieren ergeben ebenfalls keinen Sinn. Nur 398 // "Nachrichten loeschen" bleibt erreichbar (ueber deleteConversation(), 399 // nicht Teil dieser Sperre). 400 // Geloeschte/getrashte/geghostete Partner (docs/chat-todo.md §43 T7) bekommen dieselbe 401 // Sperre wie "System" (kein Profil, Status, Anruf, Blockieren, keine Eingabe) plus 402 // Platzhalter-Bild und einen Hinweis statt des Eingabefelds. 403 const isDeleted = partner?.is_deleted === true; 404 const isSystem = partner?.is_system === true || isDeleted; 405 // Name selbst ist jetzt der Profil-Link (docs/chat-todo.md #20.8, 406 // ersetzt den frueheren separaten .chat-conversation-profile-link-Button) 407 // - href-Aufbau 1:1 wie zuvor dort. 408 if (this.hasPartnerNameTarget) { 409 this.partnerNameTarget.textContent = partnerDisplayName(partner); 410 this.partnerNameTarget.href = !isSystem && partner?.username ? `/profil/${encodeURIComponent(partner.username)}/` : '#'; 411 this.partnerNameTarget.classList.toggle('chat-conversation-partner-name-no-link', isSystem); 412 } 413 if (this.hasPartnerAvatarTarget && partner) { 414 this.partnerAvatarTarget.src = isDeleted ? deletedAvatarUrl() : avatarUrl(partner.id, partner.picture_cache_break); 415 } 416 // Online-Status-Punkt am Avatar - gleiche Bedeutung wie in der 417 // Partnerliste (chat_panel_controller.js#renderPartners()), fehlte hier 418 // bisher (Nutzer-Feedback: im schmalen/kompakten Modus, wo Liste und 419 // Konversation sich abwechseln statt beide sichtbar zu sein, war der 420 // Online-Status waehrend des Schreibens dadurch gar nicht sichtbar). 421 if (this.hasPartnerOnlineStatusTarget) { 422 this.partnerOnlineStatusTarget.classList.toggle('online', !isSystem && partner?.status === 'online'); 423 this.partnerOnlineStatusTarget.classList.toggle('away', !isSystem && partner?.status === 'away'); 424 this.partnerOnlineStatusTarget.classList.toggle('hidden', isSystem); 425 } 426 // Statuszeile unter dem Namen (docs/chat-todo.md #20.8) - denselben 427 // Status wie der Punkt oben, nur als Text. 'away' faellt hier schon 428 // auf einen Text durch, auch wenn der Wert serverseitig noch nirgends 429 // gesendet wird (§20.8-Away-Status ist eigenstaendig zurueckgestellt, 430 // siehe docs/chat-todo.md) - kein Zusatzaufwand, wenn er spaeter kommt. 431 if (this.hasPartnerStatusTextTarget) { 432 const statusLabels = { online: 'Online', away: 'Abwesend', offline: 'Offline' };
433 this.partnerStatusTextTarget.textContent = isSystem ? '' : (statusLabels[partner?.status] ?? statusLabels.offline); 434 this.partnerStatusTextTarget.classList.toggle('hidden', isSystem); 435 } 436 // Anrufe bleiben freundschaftsgebunden (AV-Chat-Gate, siehe 437 // chat_client.js#startCall()) - die Icons hier schon vorab ausgrauen 438 // statt erst beim Klick den Freundschafts-Hinweisdialog zu zeigen. 439 const canCall = !isSystem && partner?.is_friend === true; 440 // Kein hasCallButtonTargets-Check: den gibt es bei Stimulus nur im Singular 441 // (hasCallButtonTarget), der Block lief dadurch nie - callButtonTargets ist ohne 442 // Treffer ohnehin ein leeres Array. 443 { 444 this.callButtonTargets.forEach((btn) => { 445 if (btn.dataset.callTitle === undefined) { 446 btn.dataset.callTitle = btn.title; 447 } 448 btn.classList.toggle('hidden', isSystem); 449 btn.disabled = !canCall; 450 btn.title = canCall ? btn.dataset.callTitle : 'Nur mit befreundeten Nutzern möglich'; 451 }); 452 } 453 if (this.hasBlockButtonTarget) { 454 this.blockButtonTarget.classList.toggle('hidden', isSystem); 455 } 456 this.#renderPartnerSettings(partner); 457 if (this.hasFormTarget) { 458 this.formTarget.classList.toggle('hidden', isSystem); 459 } 460 this.#currentUsername = partner?.username ?? null; 461 // in_audit ("Profilpruefung", isMessagesDeliveryVerified() in 462 // src/Entity/User.php) - waehrend dessen kann der Partner nicht 463 // schreiben (serverseitig durchgesetzt, ChatController::send()), 464 // gleicher Hinweis wie im alten Chat (templates/chat/messageview.html.twig). 465 // Kein eigener Text-Hinweis fuer isSystem (Nutzer-Feedback) - das 466 // ausgeblendete Eingabeformular spricht dort fuer sich. 467 if (this.hasPartnerStatusTarget) { 468 const inAudit = partner?.in_audit === 1; 469 this.partnerStatusTarget.textContent = isDeleted 470 ? 'Dieses Mitglied ist nicht mehr dabei. Du kannst nicht mehr antworten.' 471 : (inAudit ? 'Der Nutzer wird derzeit überprüft und kann erst anschlieÃend (wieder) im Chat schreiben' : ''); 472 this.partnerStatusTarget.classList.toggle('hidden', !inAudit && !isDeleted); 473 } 474 this.#renderPartnerTags(partner); 475 } 476 477 // ---- Tags pro Konversation (docs/chat-todo.md §43) ---- 478 479 // Chips unter dem Kopf, nur fuer den Nutzer selbst - Klick oeffnet den Tag-Dialog. 480 #renderPartnerTags(partner) { 481 // "Tags â¦" im Kebab-Menue nur, solange Tags bearbeitet werden duerfen (docs/chat-todo.md §46). 482 if (this.hasTagMenuButtonTarget) this.tagMenuButtonTarget.classList.toggle('hidden', !TAG_EDITING_ENABLED); 483 if (!this.hasPartnerTagsTarget) return; 484 const byId = new Map(this.chatClient.tags.map((tag) => [tag.id, tag.name])); 485 const names = (partner?.tags ?? []).map((id) => byId.get(id)).filter(Boolean); 486 this.partnerTagsTarget.innerHTML = names.map((name) => ` 487 <span class="chat-tag-chip"> 488 <svg class="icon" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#label"></use></svg>${escapeHtml(name)} 489 </span> 490 `).join(''); 491 this.partnerTagsTarget.classList.toggle('hidden', names.length === 0); 492 this.partnerTagsTarget.classList.toggle('chat-conversation-tags-readonly', !TAG_EDITING_ENABLED); 493 } 494 495 // Klick auf die Ordner/Tags unter dem Kopf oeffnet den Tag-Dialog - nur, wenn bearbeitet werden darf. 496 onPartnerTagsClick(event) { 497 if (TAG_EDITING_ENABLED) this.showTagDialog(event); 498 } 499 500 #tagDialogSelection = new Set(); 501 502 // Ausklappbare Tag-Auswahl im Kebab-Menue (wie "Stummschalten â¦"): die zugeordneten Tags 503 // und die meistgenutzten uebrigen, zusammen hoechstens MENU_TAG_LIMIT - der Rest ueber den 504 // Dialog. Ein Klick speichert sofort, das Menue bleibt offen. 505 // Reihenfolge beim Aufklappen festgehalten - sonst spraenge ein gerade angehakter Tag nach 506 // oben, unter dem Mauszeiger weg. 507 #tagOptionsOrder = null; 508 509 toggleTagOptions() { 510 if (!this.hasTagOptionsTarget) return; 511 const opening = this.tagOptionsTarget.classList.contains('hidden'); 512 if (opening) { 513 this.#tagOptionsOrder = null; 514 this.#renderTagOptions(); 515 } 516 this.tagOptionsTarget.classList.toggle('hidden', !opening); 517 } 518 519 #renderTagOptions() { 520 if (!this.hasTagOptionsTarget) return; 521 const partner = this.chatClient.state.getPartner(this.partnerIdValue); 522 const assigned = new Set(partner?.tags ?? []); 523 const byName = (a, b) => a.name.localeCompare(b.name, 'de'); 524 const tags = this.chatClient.tags; 525 const byId = new Map(tags.map((tag) => [tag.id, tag])); 526 this.#tagOptionsOrder ??= [ 527 ...tags.filter((tag) => assigned.has(tag.id)).sort(byName), 528 ...tags.filter((tag) => !assigned.has(tag.id)).sort((a, b) =>
528 b.count - a.count || byName(a, b)), 529 ].slice(0, Math.max(MENU_TAG_LIMIT, assigned.size)).map((tag) => tag.id); 530 // Inzwischen geloeschte Tags fallen weg. 531 const shown = this.#tagOptionsOrder.map((id) => byId.get(id)).filter(Boolean); 532 const more = tags.length > shown.length ? 'Alle Tags / neuer Tag â¦' : 'Neuer Tag â¦'; 533 this.tagOptionsTarget.innerHTML = shown.map((tag) => ` 534 <button type="button" class="${assigned.has(tag.id) ? 'chat-tag-option-checked' : ''}" role="menuitemcheckbox" 535 aria-checked="${assigned.has(tag.id) ? 'true' : 'false'}" 536 data-action="click->chat-conversation#toggleTagFromMenu" data-tag-id="${tag.id}"> 537 <svg class="icon chat-tag-option-check" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#check"></use></svg> 538 <span>${escapeHtml(tag.name)}</span> 539 </button> 540 `).join('') + ` 541 <button type="button" data-action="click->chat-conversation#showTagDialog">${more}</button> 542 `; 543 } 544 545 async toggleTagFromMenu(event) { 546 event.stopPropagation(); 547 const tagId = parseInt(event.currentTarget.dataset.tagId, 10); 548 const partner = this.chatClient.state.getPartner(this.partnerIdValue); 549 const tags = new Set(partner?.tags ?? []); 550 if (tags.has(tagId)) { 551 tags.delete(tagId); 552 } else { 553 tags.add(tagId); 554 } 555 try { 556 await this.chatClient.setPartnerTags(this.partnerIdValue, [...tags]); 557 } catch (e) { 558 alert(e?.body?.message ?? 'Die Tags konnten nicht gespeichert werden.'); 559 } 560 this.#renderTagOptions(); 561 } 562 563 showTagDialog(event) { 564 event?.stopPropagation(); 565 this.closeMenu(); 566 if (!this.hasTagDialogTarget) return; 567 const partner = this.chatClient.state.getPartner(this.partnerIdValue); 568 this.#tagDialogSelection = new Set(partner?.tags ?? []); 569 this.#setTagDialogError(''); 570 this.tagDialogInputTarget.value = ''; 571 this.#renderTagDialogList(); 572 this.tagDialogTarget.classList.remove('hidden'); 573 } 574 575 closeTagDialog() { 576 this.tagDialogTarget?.classList.add('hidden'); 577 } 578 579 #renderTagDialogList() { 580 if (!this.hasTagDialogListTarget) return; 581 const tags = this.chatClient.tags; 582 this.tagDialogListTarget.innerHTML = tags.length === 0 583 ? '<li class="chat-tag-dialog-empty">Noch keine Tags â leg unten deinen ersten an.</li>' 584 : tags.map((tag) => ` 585 <li> 586 <label> 587 <input type="checkbox" value="${tag.id}" ${this.#tagDialogSelection.has(tag.id) ? 'checked' : ''} 588 data-action="change->chat-conversation#toggleTagInDialog"> 589 ${escapeHtml(tag.name)} 590 </label> 591 </li> 592 `).join(''); 593 } 594 595 toggleTagInDialog(event) { 596 const tagId = parseInt(event.currentTarget.value, 10); 597 if (event.currentTarget.checked) { 598 this.#tagDialogSelection.add(tagId); 599 } else { 600 this.#tagDialogSelection.delete(tagId); 601 } 602 } 603 604 // Neuer Tag direkt im Dialog - wird angelegt und gleich angehakt, gespeichert wird die 605 // Zuordnung erst mit "Speichern". 606 async createTagInDialog(event) { 607 event.preventDefault(); 608 const name = this.tagDialogInputTarget.value.trim(); 609 if (name === '') return; 610 try { 611 const tag = await this.chatClient.createTag(name); 612 this.#tagDialogSelection.add(tag.id); 613 this.tagDialogInputTarget.value = ''; 614 this.#setTagDialogError(''); 615 this.#renderTagDialogList(); 616 } catch (e) { 617 this.#setTagDialogError(e?.body?.message ?? 'Der Tag konnte nicht angelegt werden.'); 618 } 619 } 620 621 async saveTagDialog() { 622 try { 623 await this.chatClient.setPartnerTags(this.partnerIdValue, [...this.#tagDialogSelection]); 624 this.closeTagDialog(); 625 } catch (e) { 626 this.#setTagDialogError(e?.body?.message ?? 'Die Tags konnten nicht gespeichert werden.'); 627 } 628 } 629 630 #setTagDialogError(message) { 631 if (!this.hasTagDialogErrorTarget) return; 632 this.tagDialogErrorTarget.textContent = message; 633 this.tagDialogErrorTarget.classList.toggle('hidden', message === ''); 634 } 635 636 onUpdate(partnerId, forceScroll = false, prependedCount = 0) { 637 if (partnerId === this.partnerIdValue) { 638 this.render(partnerId, forceScroll, prependedCount); 639 } 640 } 641 642 // forceScroll: signalisiert "es gibt neuen Inhalt, der urspruenglich 643 // uebersehen worden sein koennte" - aktuell nur der Nachrichten-Abgleich 644 // nach einem WebSocket-Reconnect (#resyncOpenConversations() in 645 // chat_client.js, feuert nur noch bei tatsaechlicher Aenderung). Reisst
646 // den Nutzer damit aber NICHT mehr aktiv nach unten, wenn er gerade 647 // woanders liest (Nutzer-Vorgabe 2026-09-18, docs/chat-todo.md #22.10- 648 // Folgegespraech: "dann lieber dort bleiben, aber den Scroll-down-Button 649 // markieren" - andere Chat-Apps scrollen ebenfalls nur automatisch mit, 650 // wenn man schon (nahe) am unteren Rand ist). Wie gehabt automatisch 651 // scrollen, wenn man ohnehin schon (nahe) am unteren Rand war - sonst 652 // wuerde jede neue Nachricht oder auch nur ein Tippt-gerade-Update den 653 // Nutzer beim Lesen aelterer Nachrichten nach unten reissen. 654 // docs/chat-todo.md #24, Szenario C/E: baut die Zeilen NICHT mehr bei 655 // jedem Aufruf komplett neu auf (siehe #currentRows-Kommentar oben) - 656 // stattdessen wird je nach Art der Aenderung entschieden: 657 // - Partnerwechsel/erster Aufruf/Liste geschrumpft (z.B. fehlgeschlagener 658 // Versand entfernt, "alle Nachrichten loeschen"): voller Rebuild. 659 // - Aeltere Nachrichten vorangestellt (loadOlderHistory(), erkennbar am 660 // dritten Parameter): nur die neuen aelteren Zeilen vorne einfuegen. 661 // - Sonst mehr Nachrichten als zuletzt (neue eigene/fremde Nachricht): 662 // nur die neuen Zeilen hinten anhaengen. 663 // - Gleich viele Nachrichten (z.B. 'seen'-Update an einer bestehenden 664 // Nachricht - der INHALT einer Zeile kann sich aendern, ohne dass sich 665 // die Anzahl aendert): kein guenstiger Kurzweg moeglich, voller Rebuild. 666 // Jede der vier Rebuild-/Kurzweg-Methoden ruft dabei bereits die passende 667 // VirtualMessageList-Methode (reset()/appendRows()/prependRows()/ 668 // replaceLastRow()) auf, die selbst zielgerichtet nur die dadurch 669 // tatsaechlich neuen/geaenderten Zeilen ins DOM bringt und misst (siehe 670 // #fullyRebuildRows() etc. unten sowie core/virtual_message_list.js, 671 // docs/chat-todo.md #28) - kein zusaetzlicher Aufruf hier noetig. 672 // #wireVoicePlayers()/Bild-Reflow-Wiring laufen ueber den 673 // onRowsRendered-Callback (siehe connect()), der synchron innerhalb dieser 674 // Methoden feuert und nur die Zeilen trifft, die dadurch tatsaechlich neu 675 // ins DOM kamen. 676 render(partnerId, forceScroll = false, prependedCount = 0) { 677 if (!this.hasMessagesTarget || !this.#virtualList) return; 678 const wasNearBottom = this.#isNearBottom(); 679 // Bugfix-Nachtrag docs/chat-todo.md #34: ein erzwungener Reconnect 680 // (Dock/Float-Wechsel per echtem appendChild(), oder Turbo-Pluck-and- 681 // Reinsert - siehe pendingCloseTimers-Kommentar oben) erzeugt eine 682 // FRISCHE Controller-Instanz (#currentRowsPartnerId ist dann noch 683 // null) fuer dieselbe, bereits offene Konversation - messagesTarget 684 // ist dabei dieselbe <ul> wie vorher und traegt zu diesem Zeitpunkt 685 // noch den ALTEN, echten Zeileninhalt samt data-row-index, bis 686 // #fullyRebuildRows() unten ihn wegwirft. Nur in GENAU diesem Fall 687 // (frische Instanz, nicht schon near bottom) lohnt sich ein Anker aus 688 // dem noch vorhandenen DOM - bei einem echten Partnerwechsel AUF 689 // DERSELBEN, bereits verbundenen Instanz (Reuse per 690 // partnerIdValueChanged()) ist #currentRowsPartnerId dagegen die 691 // VORHERIGE, andere Partner-ID (nicht null) - Anker bleibt dort 692 // bewusst ungenutzt, sonst wuerde eine neu geoeffnete Konversation 693 // faelschlich zu einer Zeilen-Position der ALTEN Konversation 694 // springen. 695 const scrollAnchorRowIndex = (!wasNearBottom && this.#currentRowsPartnerId === null) 696 ? this.#captureScrollAnchor() 697 : null; 698 const messages = this.chatClient.getMessages(partnerId); 699 const isTyping = this.chatClient.state.isTyping(partnerId); 700 const partner = this.chatClient.state.getPartner(partnerId); 701 // Fuers Freundschafts-Kartenlayout in renderSystemMessage() (eigenes + 702 // Partner-Profilbild) - myId/myCacheBreak liegen bereits synchron auf 703 // der ChatClient-Instanz (in connect() einmalig geladen), kein 704 // zusaetzlicher async-Umweg noetig. 705 // username fuer die Geburtstagskarte, profileStatus fuer die 706 // Profilanfrage-Karte (docs/chat-todo.md #40.1, siehe 707 // #ensureProfileStatus()). 708 const me = { 709 id: this.chatClient.myId, 710 cacheBreak: this.chatClient.myCacheBreak, 711 username: this.chatClient.myUsername, 712 profileStatus: this.chatClient.myProfileStatus, 713 }; 714 this.#ensureProfileStatus(messages, partnerId); 715 716 if (partnerId !== this.#currentRowsPartnerId || messages.length < this.#renderedCount) { 717 this.#fullyRebuildRows(messages, partnerId, partner, me); 718 if (scrollAnchorRowIndex !== null) { 719 this.#virtualList.scrollToRowIndex(scrollAnchorRowIndex, this.messagesTarget.clientHeight); 720 } 721 } else if (prependedCount > 0) { 722 this.#prependOlderMessageRows(messages, prependedCount, partnerId, partner, me); 723 } else if (messages.length > this.#renderedCount) {
724 this.#appendNewMessageRows(messages, partnerId, partner, me); 725 } else if (this.#isLastMessageOnlyContentChange(messages)) { 726 // Haeufigster "gleiche Laenge"-Fall: die eigene, gerade erst 727 // optimistisch angehaengte Nachricht wird durch ihre 728 // server-bestaetigte Version ersetzt (ChatState#replaceOptimisticMessage(), 729 // gleicher guid, nur id/date/toCheck aendern sich) - chat_client.js# 730 // sendMessage() loest dafuer einen ZWEITEN 'message:received'-Dispatch 731 // aus. Ohne diesen Kurzweg waere DAS bei jedem einzelnen Sendevorgang 732 // ein voller Rebuild gewesen, selbst nach dem Anhaenge-Fix oben (siehe 733 // Lasttest-Befund: 2 volle Rebuilds pro Sendevorgang statt 0). 734 this.#replaceLastMessageRow(messages, partnerId, partner, me); 735 } else { 736 // Sonstiger Fall mit unveraenderter Laenge - z.B. eine Nachricht 737 // IRGENDWO in der Historie aendert sich (Freundschaftsanfrage-Status, 738 // 'seen'-Update). Kein sicherer Kurzweg bekannt, voller Rebuild. 739 this.#fullyRebuildRows(messages, partnerId, partner, me); 740 } 741 742 this.#setTypingRow(isTyping); 743 if (wasNearBottom) { 744 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; 745 } else if (forceScroll && this.hasScrollToBottomBtnTarget) { 746 this.scrollToBottomBtnTarget.classList.add('has-new'); 747 } 748 this.updateScrollToBottomVisibility(); 749 } 750 751 // Eingehende Profilanfrage (Typ 8, Fragen/Hobbys/Foto) braucht den 752 // eigenen Profil-Ausfuellstand (docs/chat-todo.md #40.1) - wird erst dann 753 // einmalig geladen und danach neu gerendert. #currentRowsPartnerId = null 754 // erzwingt den vollen Rebuild inkl. Scroll-Anker (wie bei einer frischen 755 // Controller-Instanz, siehe render()). 756 #ensureProfileStatus(messages, partnerId) { 757 if (this.chatClient.myProfileStatus || this.#profileStatusRequested) return; 758 if (!messages.some((m) => m.type === 8 && messageDirection(m) === 'in')) return; 759 this.#profileStatusRequested = true; 760 this.chatClient.loadMyProfileStatus().then((status) => { 761 if (!status || this.partnerIdValue !== partnerId) return; 762 this.#currentRowsPartnerId = null; 763 this.render(partnerId); 764 }); 765 } 766 767 // Erkennt den haeufigsten "gleiche Laenge"-Fall zuverlaessig: guid wird nur 768 // von SELBST gesendeten Nachrichten gesetzt (sendMessage() in chat_client.js) - 769 // eine eingehende Nachricht ohne guid faellt hier also immer durch (sicherer 770 // Rueckfall auf den vollen Rebuild), ebenso wenn sich der guid der letzten 771 // Nachricht seit dem letzten Render geaendert hat (dann ist es keine reine 772 // Inhaltsersetzung derselben Nachricht, sondern etwas anderes). 773 #isLastMessageOnlyContentChange(messages) { 774 if (!messages.length) return false; 775 const last = messages[messages.length - 1]; 776 return last.guid !== undefined && last.guid === this.#lastRenderedGuid; 777 } 778 779 #replaceLastMessageRow(messages, partnerId, partner, me) { 780 // Tipp-Indikator-Zeile (falls angehaengt) muss zuerst weg, sonst wuerde 781 // gleich FAELSCHLICH sie statt der eigentlichen letzten Nachrichten-Zeile 782 // ueberschrieben (siehe #setTypingRow()-Kommentar - sie sitzt immer ganz 783 // am Ende von #currentRows). 784 this.#setTypingRow(false); 785 const last = messages[messages.length - 1]; 786 this.#currentRows[this.#currentRows.length - 1] = renderMessage(last, partnerId, partner, this.readReceiptsMode, me); 787 this.#lastRenderedGuid = last.guid; 788 this.#virtualList.replaceLastRow(this.#currentRows); 789 } 790 791 #fullyRebuildRows(messages, partnerId, partner, me) { 792 this.#currentRows = renderMessageRows(messages, partnerId, partner, this.readReceiptsMode, me, null); 793 this.#renderedCount = messages.length; 794 this.#currentRowsPartnerId = partnerId; 795 this.#firstRenderedDateKey = messages.length ? dateKeyFor(messages[0].date) : null; 796 this.#lastRenderedGuid = messages.length ? messages[messages.length - 1].guid : undefined; 797 // #currentRows wurde komplett neu gebaut - eine evtl. vorher angehaengte 798 // Tipp-Indikator-Zeile ist damit weg, #setTypingRow() haengt sie in 799 // render() gleich danach bei Bedarf wieder frisch an. 800 this.#typingRowAppended = false;
801 this.#virtualList.reset(this.#currentRows); 802 } 803 804 // Nur die seit dem letzten Render NEUEN (hinten angekommenen) Nachrichten in 805 // Zeilen umwandeln und anhaengen, statt alle bisherigen erneut zu 806 // durchlaufen - der eigentliche Fix fuer Szenario C bei sehr grossen 807 // Konversationen (siehe Kommentar bei #currentRows oben). 808 #appendNewMessageRows(messages, partnerId, partner, me) { 809 this.#setTypingRow(false); 810 const newMessages = messages.slice(this.#renderedCount); 811 const lastExisting = this.#renderedCount > 0 ? messages[this.#renderedCount - 1] : null; 812 const startDateKey = lastExisting ? dateKeyFor(lastExisting.date) : null; 813 const newRows = renderMessageRows(newMessages, partnerId, partner, this.readReceiptsMode, me, startDateKey); 814 this.#currentRows.push(...newRows); 815 this.#renderedCount = messages.length; 816 this.#lastRenderedGuid = newMessages.length ? newMessages[newMessages.length - 1].guid : this.#lastRenderedGuid; 817 this.#virtualList.append
817Rows(this.#currentRows, newRows.length); 818 } 819 820 // Aeltere Nachrichten wurden vorangestellt (loadOlderHistory()) - nur fuer 821 // DIESEN neuen, aelteren Ausschnitt Zeilen bauen und vorne einfuegen. 822 // unshift() auf einem grossen Array kostet zwar selbst O(n) (JS muss alle 823 // bestehenden Eintraege verschieben), das ist aber ein reiner, guenstiger 824 // Speicher-Kopiervorgang - der eigentlich teure Teil (renderMessage() mit 825 // toLocaleTimeString()/toLocaleDateString() PRO Nachricht) faellt hier nur 826 // noch fuer den neuen, kleinen Ausschnitt an (max. 25 Nachrichten pro 827 // Nachlade-Schritt, serverseitig begrenzt - ChatRepository::getHistory()), 828 // nicht mehr fuer die komplette bisherige Liste. 829 #prependOlderMessageRows(messages, prependedCount, partnerId, partner, me) { 830 this.#setTypingRow(false); 831 const olderMessages = messages.slice(0, prependedCount); 832 const newRows = renderMessageRows(olderMessages, partnerId, partner, this.readReceiptsMode, me, null); 833 // Doppelte Datums-Trennblase am Uebergang vermeiden, wenn der neue 834 // aelteste Tag derselbe ist wie der bisher aelteste - #currentRows[0] 835 // ist per Konstruktion (siehe #fullyRebuildRows()/hier selbst, beide 836 // starten immer mit initialLastDateKey=null) garantiert dessen eigene 837 // Trennblase. 838 const newBatchLastDateKey = dateKeyFor(olderMessages[olderMessages.length - 1].date); 839 const removedDuplicateSeparator = newBatchLastDateKey === this.#firstRenderedDateKey; 840 if (removedDuplicateSeparator) { 841 this.#currentRows.shift(); 842 } 843 this.#currentRows.unshift(...newRows); 844 this.#renderedCount = messages.length; 845 this.#firstRenderedDateKey = dateKeyFor(olderMessages[0].date); 846 this.#virtualList.prependRows(this.#currentRows, newRows.length, removedDuplicateSeparator); 847 } 848 849 #setTypingRow(isTyping) { 850 if (isTyping && !this.#typingRowAppended) { 851 this.#currentRows.push('<li class="chat-typing-indicator"><span></span><span></span><span></span></li>'); 852 this.#typingRowAppended = true; 853 this.#virtualList.appendSyntheticRow(this.#currentRows); 854 } else if (!isTyping && this.#typingRowAppended) { 855 this.#currentRows.pop(); 856 this.#typingRowAppended = false; 857 this.#virtualList.removeLastRow(this.#currentRows); 858 } 859 } 860 861 // Verdrahtet Play/Pause-Button + Fortschrittsbalken gegen das versteckte 862 // <audio>-Element (docs/chat-todo.md #20.9) - fuer jeden .chat-voice-player 863 // innerhalb von root (Nachrichtenliste nach jedem render() ODER das 864 // einzelne, stabile Vorschau-Element im Aufnahme-Modal, siehe 865 // #sendRecordedVoiceMessage()-Umfeld). data-wired verhindert doppeltes 866 // Verdrahten, falls root mehrfach denselben, nicht neu erzeugten Player 867 // enthaelt (Vorschau-Player wird wiederverwendet, nicht bei jeder 868 // Aufnahme neu ins DOM gehaengt). 869 #wireVoicePlayers(root) { 870 root.querySelectorAll('.chat-voice-player').forEach((player) => { 871 if (player.dataset.wired === '1') return; 872 player.dataset.wired = '1'; 873 874 const audio = player.querySelector('.chat-voice-player-audio'); 875 const toggleBtn = player.querySelector('.chat-voice-player-toggle'); 876 const track = player.querySelector('.chat-voice-player-track'); 877 const progress = player.querySelector('.chat-voice-player-progress'); 878 const timeEl = player.querySelector('.chat-voice-player-time'); 879 if (!audio || !toggleBtn || !track || !progress || !timeEl) return; 880 881 const formatTime = (seconds) => { 882 if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; 883 const m = Math.floor(seconds / 60); 884 const s = Math.floor(seconds % 60).toString().padStart(2, '0'); 885 return `${m}:${s}`; 886 }; 887 888 audio.addEventListener('loadedmetadata', () => { 889 // Fuer den wiederverwendeten Vorschau-Player relevant (nicht 890 // fuer Nachrichten-Player, die ohnehin frisch erzeugt werden): 891 // eine neue Aufnahme laedt eine neue src in dasselbe, stabile 892 // <audio>-Element - ohne Reset wuerde der Fortschrittsbalken 893 // kurz den Endstand der vorherigen Aufnahme zeigen. 894 progress.style.width = '0%'; 895 timeEl.textContent = formatTime(audio.duration); 896 }); 897 audio.addEventListener('timeupdate', () => { 898 progress.style.width = `${audio.duration ? (audio.currentTime / audio.duration) * 100 : 0}%`; 899 timeEl.textContent = formatTime(audio.currentTime); 900 }); 901 audio.addEventListener('play', () => toggleBtn.classList.add('playing')); 902 audio.addEventListener('pause', () => toggleBtn.classList.remove('playing')); 903 audio.addEventListener('ended', () => { 904 progress.style.width = '0%'; 905 timeEl.textContent = formatTime(audio.duration); 906 }); 907 908 toggleBtn.addEventListener('click', () => { 909 if (!audio.paused) { 910 audio.pause(); 911 return; 912 } 913 // Nur eine Sprachnachricht gleichzeitig abspielen, wie in 914 // den meisten Chat-Apps ueblich. 915 root.querySelectorAll('audio.chat-voice-player-audio').forEach((other) => { 916 if (other !== audio && !other.paused) other.pause(); 917 }); 918 audio.play(); 919 }); 920 921 track.addEventListener('click', (event) => { 922 if (!audio.duration) return; 923 const rect = track.getBoundingClientRect(); 924 audio.currentTime = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1) * audio.duration; 925 }); 926 }); 927 } 928 929 // docs/chat-todo.md #28: .chat-message-image/.chat-message-map haben keine 930 // festen Masse (chat-panel.css) - das <img> aendert seine Groesse erst, 931 // wenn es fertig geladen ist. Ohne dieses Wiring wuerde eine Zeile, die 932 // VirtualMessageList schon vorher (mit der zu kleinen Vor-Lade-Hoehe) 933 // gemessen hat, fuer immer falsch gecacht bleiben - remeasureRow() stoesst 934 // eine Neu-Messung inkl. Scroll-Ausgleich an (core/virtual_message_list.js). 935 // data-heightWired verhindert doppeltes Verdrahten desselben Bildes, falls 936 // dieselbe Zeile mehrfach durch den onRowsRendered-Callback laeuft (z.B. 937 // erneutes Rendern desselben Fensters). 938 #wireImageReflow(root) { 939 root.querySelectorAll('.chat-message-image, .chat-message-map, .chat-event-card-media-image, .chat-event-card-flower').forEach((img) => { 940 if (img.dataset.heightWired === '1') return; 941 img.dataset.heightWired = '1'; 942 if (img.complete) return; // vor der Messung schon geladen - erster Messwert war bereits korrekt 943 img.addEventListener('load', () => { 944 const li = img.closest('li[data-row-index]'); 945 if (!li) return; 946 this.#virtualList?.remeasureRow(Number(li.dataset.rowIndex)); 947 }, { once: true }); 948 }); 949 } 950 951 #isNearBottom(tolerance = 80) { 952 const el = this.messagesTarget; 953 return el.scrollHeight - el.scrollTop - el.clientHeight < tolerance; 954 } 955 956 // Bugfix-Nachtrag docs/chat-todo.md #34: liest den aktuell obersten 957 // sichtbaren Zeilen-Index direkt aus dem noch vorhandenen, echten DOM 958 // (data-row-index, gestempelt von virtual_message_list.js#measureAndStamp()/ 959 // #restampFrom()) - nur sinnvoll, SOLANGE #fullyRebuildRows() unten den 960 // Inhalt noch nicht ueberschrieben hat. Liefert null, wenn nichts 961 // brauchbares gefunden wird (z.B. beim allerersten Oeffnen einer 962 // Konversation, wo noch keine echten Zeilen im DOM stehen) - von selbst 963 // ein sicheres No-Op fuer den Aufrufer. 964 #captureScrollAnchor() { 965 if (!this.hasMessagesTarget) return null; 966 // getBoundingClientRect() statt offsetTop/offsetParent (.chat-message-list 967 // hat kein position:relative, offsetTop waere damit NICHT relativ zum 968 // Scroll-Container selbst, sondern zum naechsten irgendwo weiter oben 969 // positionierten Vorfahren - viewport-relative Rects sind unabhaengig 970 // vom CSS-Positionierungskontext immer konsistent. 971 const containerTop = this.messagesTarget.getBoundingClientRect().top; 972 for (const el of this.messagesTarget.children) { 973 if (el.dataset.rowIndex === undefined) continue; 974 if (el.getBoundingClientRect().bottom >
974 containerTop) { 975 return parseInt(el.dataset.rowIndex, 10); 976 } 977 } 978 return null; 979 } 980 981 updateScrollToBottomVisibility() { 982 if (!this.hasScrollToBottomBtnTarget) return; 983 const nearBottom = this.#isNearBottom(); 984 this.scrollToBottomBtnTarget.classList.toggle('hidden', nearBottom); 985 // Markierung (.has-new, siehe render()) ist nur relevant, solange der 986 // Button ueberhaupt sichtbar ist - sobald man (wieder) nahe am unteren 987 // Rand ist, gilt der neue Inhalt als gesehen. 988 if (nearBottom) { 989 this.scrollToBottomBtnTarget.classList.remove('has-new'); 990 } 991 } 992 993 scrollToBottom() { 994 this.messagesTarget.scrollTo({ top: this.messagesTarget.scrollHeight, behavior: 'smooth' }); 995 // Sofort entfernen statt auf updateScrollToBottomVisibility() zu warten 996 // (liest scrollTop, das sich beim smooth scroll erst ueber die 997 // naechsten Frames aendert - der Klick selbst ist aber bereits ein 998 // eindeutiges "gesehen"). 999 if (this.hasScrollToBottomBtnTarget) { 1000 this.scrollToBottomBtnTarget.classList.remove('has-new'); 1001 } 1002 this.updateScrollToBottomVisibility(); 1003 } 1004 1005 // Statt eines eigenen Buttons: sobald nahe genug am oberen Rand der 1006 // Nachrichtenliste gescrollt wird, automatisch aeltere Nachrichten 1007 // nachladen. exhaustedHistoryPartners (Modul-Ebene, wie pendingCloseTimers 1008 // oben) merkt sich pro Partner, dass der komplette Verlauf schon geladen 1009 // ist, damit nicht bei jedem weiteren Scroll-Event erneut leer angefragt 1010 // wird. 1011 onMessagesScroll() { 1012 // Enger als die 80px fuer den Scroll-zu-Ende-Button (siehe 1013 // #isNearBottom()-Aufruf oben) - fuers automatische Mitscrollen bei 1014 // einer Groessenaenderung (connect()) soll nur "wirklich ganz unten" 1015 // zaehlen, sonst wuerde das Lesen leicht aelterer Nachrichten zu 1016 // schnell als "unten" gewertet. 1017 this.#stickToBottom = this.#isNearBottom(15); 1018 this.updateScrollToBottomVisibility(); 1019 // rAF-gedrosselt (nicht bei jedem einzelnen scroll-Event, das kann sehr 1020 // haeufig feuern) - betrifft nur Riesen-Batch-Konversationen 1021 // (docs/chat-todo.md #24 Szenario E, #28); bei normal grossen 1022 // Konversationen ist die komplette Liste ohnehin schon real gerendert, 1023 // #onScroll() erkennt das selbst und tut dann nichts. 1024 if (!this.#scrollRafPending) { 1025 this.#scrollRafPending = true; 1026 requestAnimationFrame(() => { 1027 this.#scrollRafPending = false; 1028 // Aktuellste Werte erst hier lesen (nicht beim Planen des Frames) - 1029 // zwischen scroll-Event und Frame kann sich weiter bewegt haben. 1030 this.#virtualList?.onScroll(this.messagesTarget.scrollTop, this.messagesTarget.clientHeight); 1031 }); 1032 } 1033 const partnerId = this.partnerIdValue; 1034 if (this.#isLoadingHistory || exhaustedHistoryPartners.has(partnerId)) return; 1035 if (this.messagesTarget.scrollTop > 100) return; 1036 this.loadOlderHistory(); 1037 } 1038 1039 async loadOlderHistory() { 1040 const partnerId = this.partnerIdValue; 1041 if (this.#isLoadingHistory) return; 1042 this.#isLoadingHistory = true; 1043 const previousHeight = this.messagesTarget.scrollHeight; 1044 // chatClient.loadOlderHistory() feuert 'message:received' (mit 1045 // detail.prepended) bereits SYNCHRON waehrend dieses await, bevor die 1046 // Kontrolle hierher zurueckkommt - #onMessageReceived hat render() an 1047 // dieser Stelle also schon (korrekt inkrementell, siehe 1048 // #prependOlderMessageRows()) ausgefuehrt. Kein eigener render()-Aufruf 1049 // hier mehr noetig (docs/chat-todo.md #24, Szenario E) - nur noch die 1050 // Scroll-Position korrigieren, previousHeight wurde ja vor dem await 1051 // erfasst, scrollHeight ist durch das bereits gelaufene render() aber 1052 // schon der neue Wert. 1053 const loaded = await this.chatClient.loadOlderHistory(partnerId); 1054 if (loaded > 0) { 1055 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight - previousHeight; 1056 } else { 1057 exhaustedHistoryPartners.add(partnerId); 1058 } 1059 this.#isLoadingHistory = false;
1060 } 1061 1062 submitMessage(event) { 1063 event.preventDefault(); 1064 const text = this.inputTarget.value.trim(); 1065 if (!text) return; 1066 const partnerId = this.partnerIdValue; 1067 this.chatClient.sendMessage(partnerId, text); 1068 this.inputTarget.value = ''; 1069 this.#autoGrowInput(); 1070 this.chatClient.setTyping(partnerId, false); 1071 // Eigene, gerade abgeschickte Nachricht immer anzeigen - unabhaengig 1072 // von render()s wasNearBottom-Heuristik (die ist fuer ANKOMMENDE 1073 // Nachrichten gedacht, damit das Lesen aelterer Historie nicht 1074 // unterbrochen wird, siehe render() oben). Bei einer mehrzeiligen 1075 // Eingabe war die Konversationsliste im Moment des synchronen 1076 // render()-Aufrufs innerhalb sendMessage() noch durch die (dann noch 1077 // nicht geleerte) hohe Eingabezeile zusammengedrueckt - wasNearBottom 1078 // fiel dadurch faelschlich negativ aus (Nutzer-Feedback: nach dem 1079 // Senden musste immer manuell nach unten gescrollt werden). 1080 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; 1081 } 1082 1083 onInput() { 1084 this.chatClient.setTyping(this.partnerIdValue, this.inputTarget.value.length > 0); 1085 } 1086 1087 // Widerrufsverzicht (§ 356 Abs. 4 BGB) vor der ersten selbst verfassten 1088 // Textnachricht - siehe chat_panel_controller.js fuer das Gegenstueck im 1089 // Erstnachricht-Dialog. Diese Instanz behandelt nur Nachrichten aus einer 1090 // bereits offenen Konversation (Sidebar oder Floating-Fenster), siehe 1091 // Filter in chatPanelOutletConnected() oben. 1092 showRevocationDialog(pending) { 1093 if (!this.hasRevocationDialogTarget) return; 1094 this.#pendingRevocationSend = pending; 1095 if (this.hasRevocationCheckboxTarget) { 1096 this.revocationCheckboxTarget.checked = false; 1097 } 1098 this.revocationDialogTarget.classList.remove('hidden'); 1099 } 1100 1101 closeRevocationDialog() { 1102 if (!this.hasRevocationDialogTarget) return; 1103 this.revocationDialogTarget.classList.add('hidden'); 1104 this.#pendingRevocationSend = null; 1105 } 1106 1107 async confirmRevocationWaiver() { 1108 if (this.hasRevocationCheckboxTarget && !this.revocationCheckboxTarget.checked) return; 1109 const pending = this.#pendingRevocationSend; 1110 this.closeRevocationDialog(); 1111 if (pending) { 1112 await this.chatClient.confirmRevocationWaiver(pending); 1113 } 1114 } 1115 1116 // AV-Chat-Freundschaftshinweis - siehe #onFriendRequired oben fuer den 1117 // Hintergrund (frueher Singleton in chat_panel_controller.js). 1118 showFriendDialog(partnerId) { 1119 if (!this.hasFriendDialogTarget) return; 1120 const partner = this.chatClient.state.getPartner(partnerId); 1121 const username = partner?.username ?? 'diesem Kontakt'; 1122 // Nutzer-Vorgabe 2026-09-16: "danach soll ein senden auch für längere 1123 // Zeit nicht möglich sein. Am besten solange die Anfrage noch 1124 // existiert" - has_pending_friend_request_from_me (ChatController:: 1125 // getChatPartners()) ist genau das, es gibt keine automatische 1126 // Loeschung/Ablauf einer Freundschaftsanfrage (FriendsController:: 1127 // sendRequest() lehnt eine zweite ohnehin serverseitig mit 1128 // FRIEND_REQUEST_SENT_ALREADY ab - hier nur die passende, sich 1129 // selbst erklaerende Client-Ansicht dazu statt eines stillen 1130 // Fehlschlags beim erneuten Klick auf "Senden"). 1131 const pending = partner?.has_pending_friend_request_from_me === true; 1132 this.friendDialogTextTarget.textContent = pending 1133 ? `Du hast ${username} bereits eine Freundschaftsanfrage gesendet. Video-/Audio-Anrufe sind erst möglich, sobald sie angenommen wurde.` 1134 : `Video-/Audio-Anrufe sind nur mit Freunden möglich. Möchtest du ${username} eine Freundschaftsanfrage senden?`; 1135 if (this.hasFriendDialogAvatarTarget && partner) { 1136 this.friendDialogAvatarTarget.src = avatarUrl(partnerId, partner.picture_cache_break, 96); 1137 } 1138 if (this.hasFriendDialogSendBtnTarget) { 1139 this.friendDialogSendBtnTarget.classList.toggle('hidden', pending); 1140 } 1141 if (this.hasFriendDialogCloseBtnTarget) { 1142 this.friendDialogCloseBtnTarget.textContent = pending ? 'SchlieÃen' : 'Abbrechen'; 1143 } 1144 this.friendDialogTarget.dataset.partnerId = partnerId; 1145 this.friendDialogTarget.classList.remove('hidden'); 1146 } 1147 1148 closeFriendDialog() { 1149 if (!this.hasFriendDialogTarget) return; 1150 this.friendDialogTarget.classList.add('hidden'); 1151 } 1152 1153 async sendFriendRequestFromDialog() { 1154 const partnerId = this.friendDialogTarget.dataset.partnerId; 1155 try { 1156 await sendFriendRequest(partnerId); 1157 // Nutzer-Vorgabe 2026-09-16: "Freundschaftsanfrage gesendet soll 1158 // auch im eigenen chat auftauchen" - der Server legt dafuer schon 1159 // laenger eine Chat::CHAT_MESSAGE_TYPE_FRIEND_REQUEST-Nachricht an 1160 // (FriendsController::sendRequest() -> ChatManager:: 1161 // sendSpecialMessage()), aber ueber einen anderen Endpunkt als 1162 // sendMessage() - ohne diesen gezielten Refetch blieb die eigene, 1163 // gerade offene Konversation bis zum naechsten Push/Resync 1164 // unveraendert. 1165 await this.chatClient.refreshConversation(parseInt(partnerId, 10)); 1166 // Lokalen Status sofort setzen statt auf den naechsten /chat/ 1167 // partners-Refetch zu warten (kann sich per Push verzoegern, 1168 // siehe docs/general_todo.md "WebSocket-Verbindung kann zur 1169 // Zombie-Verbindung werden") - verhindert, dass ein sofortiger 1170 // erneuter Anruf-Versuch denselben Dialog nochmal im "Senden"- 1171 // Zustand statt im "bereits gesendet"-Zustand zeigt. 1172 const partner = this.chatClient.state.getPartner(parseInt(partnerId, 10)); 1173 if (partner) partner.has_pending_friend_request_from_me = true; 1174 } catch (e) { 1175 // Bewusst still: der Dialog zeigt jetzt schon vorher den 1176 // "bereits gesendet"-Zustand an (siehe showFriendDialog()), ein 1177 // Fehlschlag hier ist also nur noch ein seltener Race-Fall 1178 // (z.B. zwei schnell aufeinanderfolgende Klicks). 1179 } finally { 1180 this.closeFriendDialog(); 1181 } 1182 } 1183
1184 // Eingabefeld ist eine <textarea> (mehrzeilig bei Fokus, siehe chat-panel.css 1185 // .chat-input:focus). Verhalten umschaltbar ueber das Einstellungsmenue 1186 // (docs/chat-todo.md #20.10, getEnterMode()) - Default jetzt 'enter_newline' 1187 // (Nutzer-Vorgabe 2026-09-14/15, 1:1 wie im alten Chat: Enter fuegt eine 1188 // Zeile ein, erst Umschalt+Enter sendet), Alternative 'enter_send' ist das 1189 // bisherige hartcodierte Verhalten (Enter sendet). Beide Modi akzeptieren 1190 // sowohl Umschalt- als auch Strg-Taste als Modifier (genau wie im alten 1191 // Chat, chat/js/app.js: `event.ctrlKey || event.shiftKey`) - kein 1192 // preventDefault() noetig, wenn NICHT gesendet werden soll: die <textarea> 1193 // fuegt den Zeilenumbruch dann ganz von selbst ein (Browser-Default). 1194 onInputKeydown(event) { 1195 if (event.key !== 'Enter') return; 1196 const modifierPressed = event.shiftKey || event.ctrlKey; 1197 const sendOnPlainEnter = getEnterMode() === 'enter_send'; 1198 const shouldSend = sendOnPlainEnter ? !modifierPressed : modifierPressed; 1199 if (shouldSend) { 1200 event.preventDefault(); 1201 this.formTarget.requestSubmit(); 1202 } 1203 } 1204 1205 // Einstellungsdialog selbst ist seit docs/chat-todo.md #32 PANEL-weit 1206 // (chat_panel_controller.js#showSettingsDialog() etc.) - hier bleibt nur 1207 // noch das Live-Anwenden auf die eigene, gerade offene Instanz. 1208 1209 // enterkeyhint steuert nur das Label der Eingabetaste auf Onscreen- 1210 // Tastaturen (Mobilgeraete) - rein kosmetisch, aendert das tatsaechliche 1211 // Verhalten nicht (das entscheidet weiterhin onInputKeydown() oben). 1212 #applyEnterKeyHint() { 1213 if (!this.hasInputTarget) return; 1214 this.inputTarget.enterKeyHint = getEnterMode() === 'enter_send' ? 'send' : 'enter'; 1215 } 1216 1217 toggleUploadMenu() { 1218 if (this.hasUploadMenuDropdownTarget) this.uploadMenuDropdownTarget.classList.toggle('hidden'); 1219 } 1220 1221 closeUploadMenu() { 1222 if (this.hasUploadMenuDropdownTarget) this.uploadMenuDropdownTarget.classList.add('hidden'); 1223 } 1224 1225 // Grid wird erst beim ersten OEffnen befuellt statt beim Template-Rendern 1226 // (79 Buttons pro Konversations-Instanz waeren unnoetiger DOM-Aufwand fuer 1227 // Konversationen, die nie einen Emoji-Picker oeffnen). 1228 toggleEmojiMenu() { 1229 if (!this.hasEmojiMenuDropdownTarget) return; 1230 if (this.emojiMenuDropdownTarget.childElementCount === 0) { 1231 for (const emoji of EMOJI_LIST) { 1232 const btn = document.createElement('button'); 1233 btn.type = 'button'; 1234 btn.className = 'chat-emoji-menu-item'; 1235 btn.textContent = emoji; 1236 btn.dataset.action = 'click->chat-conversation#insertEmoji'; 1237 this.emojiMenuDropdownTarget.appendChild(btn); 1238 } 1239 } 1240 this.emojiMenuDropdownTarget.classList.toggle('hidden'); 1241 } 1242 1243 closeEmojiMenu() { 1244 if (this.hasEmojiMenuDropdownTarget) this.emojiMenuDropdownTarget.classList.add('hidden'); 1245 } 1246 1247 // Fuegt das Emoji an der aktuellen Cursor-Position in die Textarea ein 1248 // (kein contenteditable/img-Ersatz wie im alten Chat noetig - Browser 1249 // rendern Emoji-Unicode in <textarea> nativ). 1250 insertEmoji(event) { 1251 const emoji = event.currentTarget.textContent; 1252 const input = this.inputTarget; 1253 const start = input.selectionStart ?? input.value.length; 1254 const end = input.selectionEnd ?? input.value.length; 1255 input.value = input.value.slice(0, start) + emoji + input.value.slice(end); 1256 const caret = start + emoji.length; 1257 input.setSelectionRange(caret, caret); 1258 input.focus(); 1259 input.dispatchEvent(new Event('input', { bubbles: true })); 1260 this.closeEmojiMenu(); 1261 } 1262 1263 pickAttachment() { 1264 this.closeUploadMenu(); 1265 this.attachmentInputTarget.click(); 1266 } 1267 1268 async onAttachmentSelected(event) { 1269 const file = event.target.files[0]; 1270 event.target.value = ''; 1271 if (!file) return; 1272 try { 1273 // 1 = Chat::CHAT_MESSAGE_TYPE_IMAGE (Chat.php) - render() oben 1274 // behandelt Nachrichten mit diesem Typ bereits als Bild. 1275 await this.chatClient.sendAttachment(this.partnerIdValue, 1, file); 1276 } catch (error) { 1277 // Age-Range (docs/chat-todo.md #24) - siehe gleicher Check in 1278 // #restoreFailedMessageText() weiter oben. 1279 if (error?.body?.message_code === 'NOT_IN_AGE_RANGE') { 1280 alert('Diese Person hat eingestellt, in welcher Altersspanne sie kontaktiert werden möchte - dein Bild konnte deshalb nicht gesendet werden.'); 1281 } else { 1282 alert('Bild konnte nicht gesendet werden.'); 1283 } 1284 } 1285 } 1286
1287 // Sprachnachricht-Modal (docs/chat-todo.md #7) statt Press-and-Hold auf 1288 // einem Leisten-Button - expliziter "Aufnahme starten"-Schritt gibt dem 1289 // Mikrofon-Berechtigungsdialog des Browsers einen erkennbaren Kontext. 1290 openVoiceModal() { 1291 this.closeUploadMenu(); 1292 if (this.hasVoiceModalTarget) this.voiceModalTarget.classList.remove('hidden'); 1293 } 1294 1295 // Toggle Start/Stop (Name passend zum data-action in der Vorlage): laeuft 1296 // noch keine Aufnahme, startet sie; laeuft bereits eine, stoppt der Klick 1297 // sie NUR (kein Auto-Senden mehr) - der 'stop'-Handler unten wechselt 1298 // danach in den Anhoer-Zustand, aus dem "Senden"/"Verwerfen" entscheiden. 1299 async toggleModalRecording() { 1300 if (this.#mediaRecorder) { 1301 this.#recordingCancelled = false; 1302 this.#mediaRecorder.stop(); 1303 return; 1304 } 1305 let stream; 1306 try { 1307 stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 1308 } catch (error) { 1309 alert('Mikrofonzugriff wurde nicht erlaubt.'); 1310 return; 1311 } 1312 const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']; 1313 const mimeType = candidates.find((candidate) => window.MediaRecorder?.isTypeSupported?.(candidate)); 1314 const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); 1315 const chunks = []; 1316 recorder.addEventListener('dataavailable', (event) => { 1317 if (event.data.size > 0) chunks.push(event.data); 1318 }); 1319 recorder.addEventListener('stop', () => { 1320 stream.getTracks().forEach((track) => track.stop()); 1321 clearInterval(this.#recordingTimerId); 1322 this.#recordingTimerId = null; 1323 this.#mediaRecorder = null; 1324 const durationSeconds = Math.round((Date.now() - this.#recordingStartedAt) / 1000); 1325 if (this.#recordingCancelled || durationSeconds < 1 || chunks.length === 0) { 1326 this.#closeVoiceModal(); 1327 return; 1328 } 1329 this.#recordedBlob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' }); 1330 this.#recordingDurationSeconds = durationSeconds; 1331 if (this.hasVoiceModalPreviewTarget) { 1332 this.#recordedPreviewUrl = URL.createObjectURL(this.#recordedBlob); 1333 this.voiceModalPreviewTarget.src = this.#recordedPreviewUrl; 1334 } 1335 this.#setVoiceModalState('reviewing'); 1336 }); 1337 this.#mediaRecorder = recorder; 1338 this.#recordingCancelled = false; 1339 this.#recordingStartedAt = Date.now(); 1340 this.#setVoiceModalState('recording'); 1341 this.#recordingTimerId = setInterval(() => { 1342 const elapsed = Math.round((Date.now() - this.#recordingStartedAt) / 1000); 1343 if (this.hasVoiceModalTimerTarget) { 1344 const minutes = Math.floor(elapsed / 60); 1345 const seconds = elapsed % 60; 1346 this.voiceModalTimerTarget.textContent = `${minutes}:${String(seconds).padStart(2, '0')}`; 1347 } 1348 if (elapsed >= MAX_VOICE_DURATION_SECONDS) { 1349 recorder.stop(); 1350 } 1351 }, 500); 1352 recorder.start(); 1353 } 1354 1355 // Ueber die UI nur im Anhoer-Zustand erreichbar (Aufnahme bereits 1356 // gestoppt, #recordedBlob gesetzt) - der Fallback-Zweig mit noch aktivem 1357 // Recorder greift nur, falls doch mal beide Klicks ueberlappen sollten. 1358 stopAndSendRecording() { 1359 if (this.#mediaRecorder) { 1360 this.#recordingCancelled = false; 1361 this.#mediaRecorder.addEventListener('stop', () => this.#sendRecordedVoiceMessage(), { once: true }); 1362 this.#mediaRecorder.stop(); 1363 return; 1364 } 1365 this.#sendRecordedVoiceMessage(); 1366 } 1367 1368 #sendRecordedVoiceMessage() { 1369 if (!this.#recordedBlob) return; 1370 const extension = this.#recordedBlob.type.split('/')[1]?.split(';')[0] || 'webm'; 1371 const file = new File([this.#recordedBlob], `voice-message.${extension}`, { type: this.#recordedBlob.type }); 1372 const durationSeconds = this.#recordingDurationSeconds; 1373 this.#closeVoiceModal(); 1374 // 3 = Chat::CHAT_MESSAGE_TYPE_AUDIO (Chat.php) 1375 this.chatClient.sendAttachment(this.partnerIdValue, 3, file, { duration_seconds: durationSeconds }) 1376 .catch((error) => { 1377 // Age-Range (docs/chat-todo.md #24) - siehe gleicher Check in 1378 // #restoreFailedMessageText() weiter oben. Vor dem generischen 1379 // 403-Fall geprueft, da AUDIO_REQUIRES_VERIFIED_SENDER ebenfalls 403 1380 // liefert. 1381 if (error?.body?.message_code === 'NOT_IN_AGE_RANGE') { 1382 alert('Diese Person hat eingestellt, in welcher Altersspanne sie kontaktiert werden möchte - deine Sprachnachricht konnte deshalb nicht gesendet werden.'); 1383 } else if (error?.status === 403) { 1384 alert('Sprachnachrichten sind für dein Konto noch nicht freigeschaltet.'); 1385 } else { 1386 alert('Sprachnachricht konnte nicht gesendet werden.'); 1387 } 1388 }); 1389 } 1390
1391 // Auch als "X"/Close-Klick am Modal gebunden - laeuft gerade eine 1392 // Aufnahme, wird sie abgebrochen statt gestoppt+angehoert; im Anhoer- 1393 // Zustand (oder im Ruhezustand) schliesst es nur das Modal. 1394 cancelRecording() { 1395 if (this.#mediaRecorder) { 1396 this.#recordingCancelled = true; 1397 this.#mediaRecorder.stop(); 1398 } else { 1399 this.#closeVoiceModal(); 1400 } 1401 } 1402 1403 #closeVoiceModal() { 1404 if (this.#recordedPreviewUrl) { 1405 URL.revokeObjectURL(this.#recordedPreviewUrl); 1406 this.#recordedPreviewUrl = null; 1407 } 1408 this.#recordedBlob = null; 1409 this.#recordingDurationSeconds = 0; 1410 if (this.hasVoiceModalPreviewTarget) { 1411 this.voiceModalPreviewTarget.pause(); 1412 this.voiceModalPreviewTarget.removeAttribute('src'); 1413 this.voiceModalPreviewTarget.load(); 1414 } 1415 this.#setVoiceModalState('idle'); 1416 if (this.hasVoiceModalTarget) this.voiceModalTarget.classList.add('hidden'); 1417 } 1418 1419 // state: 'idle' (vor der Aufnahme) | 'recording' (laeuft) | 'reviewing' 1420 // (gestoppt, #recordedBlob gesetzt, Anhoeren vor dem Senden moeglich). 1421 #setVoiceModalState(state) { 1422 if (this.hasVoiceModalRecordBtnTarget) { 1423 const btn = this.voiceModalRecordBtnTarget; 1424 btn.classList.toggle('chat-voice-recording', state === 'recording'); 1425 btn.classList.toggle('hidden', state === 'reviewing'); 1426 btn.title = state === 'recording' ? 'Aufnahme stoppen' : 'Aufnahme starten'; 1427 // Record/Stop-Icon-Wechsel laeuft rein per CSS ueber die 1428 // .chat-voice-recording-Klasse oben (chat-panel.css, 1429 // .chat-icon-on/-off) - kein JS-Zutun mehr noetig. 1430 } 1431 if (this.hasVoiceModalPreviewTarget) { 1432 this.voiceModalPreviewTarget.classList.toggle('hidden', state !== 'reviewing'); 1433 } 1434 if (this.hasVoiceModalActionsTarget) { 1435 this.voiceModalActionsTarget.classList.toggle('hidden', state === 'idle'); 1436 } 1437 if (this.hasVoiceModalHintTarget) { 1438 this.voiceModalHintTarget.textContent = { 1439 idle: 'Nimm eine Sprachnachricht auf.', 1440 recording: 'Aufnahme läuft ...', 1441 reviewing: 'Anhören und senden oder verwerfen.', 1442 }[state]; 1443 } 1444 if (this.hasVoiceModalTimerTarget && state === 'idle') { 1445 this.voiceModalTimerTarget.textContent = ''; 1446 } 1447 } 1448 1449 // event.params.video kommt aus data-chat-conversation-video-param an den 1450 // beiden Anruf-Buttons in _panel.html.twig - Telefon-Button (false) vs. 1451 // Video-Button (true), docs/chat-todo.md §22.6. 1452 startCall({ params }) { 1453 this.chatClient.startCall(this.partnerIdValue, params.video); 1454 } 1455 1456 toggleMenu() { 1457 if (this.hasMenuDropdownTarget) this.menuDropdownTarget.classList.toggle('hidden'); 1458 if (this.hasMuteOptionsTarget) this.muteOptionsTarget.classList.add('hidden'); 1459 } 1460 1461 closeMenu() { 1462 if (this.hasTagOptionsTarget) this.tagOptionsTarget.classList.add('hidden'); 1463 if (this.hasMenuDropdownTarget) this.menuDropdownTarget.classList.add('hidden'); 1464 } 1465 1466 // ---- Einstellungen pro Chatpartner (docs/chat-todo.md §39) ---- 1467 1468 // Beschriftung/Sichtbarkeit der Menuepunkte nach dem aktuellen Stand - laeuft ueber 1469 // #renderHeader() bei jedem Oeffnen und bei jedem partners:updated (auch nach Aenderungen 1470 // auf einem anderen Geraet). 1471 #renderPartnerSettings(partner) { 1472 const muted = partner ? this.chatClient.isMuted(partner.id) : false; 1473 if (this.hasMuteButtonTarget) this.muteButtonTarget.classList.toggle('hidden', muted); 1474 if (this.hasUnmuteButtonTarget) this.unmuteButtonTarget.classList.toggle('hidden', !muted); 1475 if (this.hasMuteOptionsTarget && muted) this.muteOptionsTarget.classList.add('hidden'); 1476 if (this.hasMuteInfoTarget) { 1477 this.muteInfoTarget.textContent = muted ? formatMutedUntil(partner) : ''; 1478 } 1479 if (this.hasPinLabelTarget) { 1480 this.pinLabelTarget.textContent = partner?.pinned ? 'Nicht mehr anheften' : 'Anheften'; 1481 } 1482 if (this.hasArchiveLabelTarget) { 1483 this.archiveLabelTarget.textContent = partner?.archived ? 'Aus dem Archiv holen' : 'Archivieren'; 1484 } 1485 if (this.hasArchiveIconTarget) { 1486 this.archiveIconTarget.setAttribute('href', `${iconSpriteUrl()}#${partner?.archived ? 'unarchive' : 'archive'}`); 1487 } 1488 } 1489 1490 toggleMuteOptions() { 1491 if (this.hasMuteOptionsTarget) this.muteOptionsTarget.classList.toggle('hidden'); 1492 } 1493 1494 mute({ params }) { 1495 this.#changePartnerSettings('mute', params.duration); 1496 } 1497 1498 unmute() { 1499 this.#changePartnerSettings('unmute'); 1500 } 1501 1502 togglePin() { 1503 const partner = this.chatClient.state.getPartner(this.partnerIdValue); 1504 this.#changePartnerSettings(partner?.pinned ? 'unpin' : 'pin'); 1505 } 1506 1507 toggleArchive() { 1508 const partner = this.chatClient.state.getPartner(this.partnerIdValue); 1509 this.#changePartnerSettings(partner?.archived ? 'unarchive' : 'archive'); 1510 } 1511 1512 async #changePartnerSettings(action, duration) { 1513 this.closeMenu(); 1514 try { 1515 await this.chatClient.updatePartnerSettings(this.partnerIdValue, action, duration); 1516 } catch (e) { 1517 // z.B. CHAT_PIN_LIMIT_REACHED - die Meldung kommt uebersetzt vom Server. 1518 alert(e?.body?.message ?? 'Die Einstellung konnte nicht gespeichert werden.'); 1519 } 1520 } 1521 1522 closeMenuIfOutside(event) { 1523 // Klicks im Dropdown selbst (z.B. "Stummschalten â¦" klappt die Dauer-Auswahl auf) 1524 // schliessen es nicht - die eigentlichen Aktionen schliessen es selbst (closeMenu()). 1525 if (this.hasMenuDropdownTarget && !this.menuDropdownTarget.classList.contains('hidden') 1526 && !event.target.closest('.chat-conversation-menu') && !event.target.closest('.chat-conversation-menu-dropdown')) { 1527 this.closeMenu(); 1528 } 1529 if (this.hasUploadMenuDropdownTarget && !this.uploadMenuDropdownTarget.classList.contains('hidden') 1530 && !event.target.closest('.chat-upload-menu')) { 1531 this.closeUploadMenu(); 1532 } 1533 if (this.hasEmojiMenuDropdownTarget && !this.emojiMenuDropdownTarget.classList.contains('hidden') 1534 && !event.target.closest('.chat-emoji-menu')) { 1535 this.closeEmojiMenu(); 1536 } 1537 if (this.hasSeenInfoPopupTarget && !this.seenInfoPopupTarget.classList.contains('hidden') 1538 && !event.target.closest('.chat-seen-info-popup') && !event.target.closest('.chat-message-seen-unknown')) { 1539 this.closeSeenInfoPopup(); 1540 } 1541 } 1542 1543 // app.chat.read_receipts_mode: 'friends_only' - Klick auf das Fragezeichen 1544 // bei einer eigenen Nachricht ohne sichtbaren Lesestatus (Partner nicht 1545 // befreundet), siehe renderSeenIndicator() unten. 1546 showSeenInfoPopup(event) { 1547 event.stopPropagation(); 1548 if (this.hasSeenInfoPopupTarget) this.seenInfoPopupTarget.classList.remove('hidden'); 1549 } 1550 1551 closeSeenInfoPopup() { 1552 if (this.hasSeenInfoPopupTarget) this.seenInfoPopupTarget.classList.add('hidden'); 1553 } 1554 1555 async deleteConversation() { 1556 this.closeMenu(); 1557 if (!confirm('Alle Nachrichten mit diesem Nutzer wirklich löschen?')) return; 1558 try { 1559 await this.chatClient.deleteConversation(this.partnerIdValue); 1560 this.chatPanelOutlet?.closeConversation(); 1561 } catch (e) { 1562 alert('Nachrichten konnten nicht gelöscht werden.'); 1563 } 1564 } 1565 1566 // Wiederverwendung der bestehenden (Plus-)Blockliste, siehe api.js#blockUser - 1567 // ROLE_BLOCKLIST-Ablehnung (Free-Account) landet hier als ChatApiError. 1568 async blockUser() { 1569 this.closeMenu(); 1570 if (!this.#currentUsername) return; 1571 if (!confirm(`${this.#currentUsername} wirklich blockieren?`)) return; 1572 try { 1573 await this.chatClient.blockUser(this.partnerIdValue, this.#currentUsername); 1574 this.chatPanelOutlet?.closeConversation(); 1575 } catch (e) { 1576 // status 0 = ChatApiError aus redirect:'manual' (siehe api.js#request) - 1577 // Symfonys AccessDeniedSubscriber liefert bei fehlender ROLE_BLOCKLIST 1578 // einen 302 statt JSON/403, unabhaengig vom XHR-Header. 1579 alert((e?.status === 0 || e?.status === 403) 1580 ? 'Blockieren ist ein Plus-Feature.' 1581 : 'Nutzer konnte nicht blockiert werden.'); 1582 } 1583 } 1584 1585 // "Antworten"/"Danke sagen"/"Nachricht schreiben" in den Ereigniskarten 1586 // (docs/chat-todo.md #40.1): fokussiert das Eingabefeld, belegt es 1587 // optional mit data-reply-text vor - nur wenn noch leer, ein bereits 1588 // angefangener Text wird nie ueberschrieben. Gesendet wird nichts. 1589 focusReply(event) { 1590 if (!this.hasInputTarget) return; 1591 const replyText = event.currentTarget.dataset.replyText; 1592 if (replyText && this.inputTarget.value.trim() === '') { 1593 this.inputTarget.value = replyText; 1594 this.inputTarget.dispatchEvent(new Event('input', { bubbles: true })); 1595 }
1596 this.inputTarget.focus(); 1597 const end = this.inputTarget.value.length; 1598 this.inputTarget.setSelectionRange(end, end); 1599 } 1600 1601 // Button in renderFlowerCard() - reine Button-Zustandsaenderung statt 1602 // eines eigenen Chat-Nachrichten-Echos: die eigentliche "Bedanke dich"- 1603 // Nachricht geht serverseitig direkt an den urspruenglichen Blumen-Sender 1604 // (ChatManager::sendSpecialMessage() in FlowerKnockController:: 1605 // flowerThanks()), nicht an diese Konversation hier. 1606 async thankForFlower(event) { 1607 const btn = event.currentTarget; 1608 const flowerKnockId = btn.dataset.flowerKnockId; 1609 btn.disabled = true; 1610 try { 1611 await this.chatClient.thankForFlower(flowerKnockId); 1612 btn.textContent = 'Du hast dich bedankt'; 1613 } catch (e) { 1614 if (e?.body?.message_code === 'FLOWER_THANKED_ALREADY') { 1615 btn.textContent = 'Bereits bedankt'; 1616 } else { 1617 btn.textContent = 'Jetzt bedanken'; 1618 btn.disabled = false; 1619 alert('Bedanken hat nicht geklappt, bitte später erneut versuchen.'); 1620 } 1621 } 1622 } 1623 1624 // Annehmen/Ablehnen einer Freundschaftsanfrage direkt aus dem Chat 1625 // (renderSystemMessage() oben, docs/chat-todo.md #12) - dieselben 1626 // bestehenden Endpunkte wie friends_index (FriendsController), keine 1627 // eigenen Chat-Endpunkte. 1628 async acceptFriendRequest(event) {
1629 await this.#answerFriendRequest(event, 'accept'); 1630 } 1631 1632 async declineFriendRequest(event) { 1633 await this.#answerFriendRequest(event, 'decline'); 1634 } 1635 1636 async #answerFriendRequest(event, action) { 1637 const actionsEl = event.currentTarget.closest('[data-friend-request-actions]'); 1638 if (!actionsEl) return; 1639 const partnerId = parseInt(event.currentTarget.dataset.partnerId, 10); 1640 const buttons = actionsEl.querySelectorAll('button'); 1641 buttons.forEach((btn) => { btn.disabled = true; }); 1642 try { 1643 if (action === 'accept') { 1644 await this.chatClient.acceptFriendRequest(partnerId); 1645 // is_friend ist jetzt aktuell (acceptFriendRequest() wartet 1646 // intern auf den Partner-Refetch, siehe chat_client.js) - ein 1647 // voller Re-Render zeigt dieselbe Anfrage-Nachricht jetzt 1648 // automatisch als "befreundet"-Karte (renderSystemMessage(), 1649 // Nutzer-Vorgabe 2026-09-16: "eigentlich braucht es nur eine 1650 // Nachricht, aber dafuer die verschiedenen Varianten der 1651 // Darstellung" - statt die Karte verschwinden zu lassen oder 1652 // eine zweite, separate Bestaetigungsnachricht anzuzeigen). 1653 this.render(partnerId); 1654 } else { 1655 await this.chatClient.declineFriendRequest(partnerId); 1656 // Kein eigener "abgelehnt"-Zustand serverseitig gespeichert 1657 // (siehe Kommentar in renderSystemMessage()) - is_friend 1658 // bleibt false, ein Re-Render wuerde die Karte hier also 1659 // wieder in den (unveraendert wirkenden) Anfrage-Zustand 1660 // zuruecksetzen statt ein Feedback zu zeigen. Deshalb weiterhin 1661 // ein rein clientseitiger Sofort-Hinweis statt eines Re-Renders. 1662 actionsEl.textContent = 'Abgelehnt'; 1663 } 1664 } catch (e) { 1665 buttons.forEach((btn) => { btn.disabled = false; }); 1666 alert('Das hat nicht geklappt, bitte später erneut versuchen.'); 1667 } 1668 } 1669 1670 // Verzoegert statt sofort - siehe pendingCloseTimers oben: nur wenn kein 1671 // open() derselben partnerId innerhalb eines Ticks nachkommt, war es ein 1672 // echtes Schliessen (z.B. Zurueck-Button, Fenster geschlossen) und keine 1673 // transiente Turbo-Wiederverbindung. 1674 disconnect() { 1675 this.#virtualList?.destroy(); 1676 this.#virtualList = null; 1677 this.#resizeObserver?.disconnect(); 1678 window.removeEventListener(ENTER_MODE_CHANGED_EVENT, this.#onEnterModeChanged); 1679 const partnerId = this.partnerIdValue; 1680 const chatClient = this.hasChatPanelOutlet ? this.chatPanelOutlet.chatClient : null; 1681 if (!chatClient) return; 1682 pendingCloseTimers.set(partnerId, setTimeout(() => { 1683 pendingCloseTimers.delete(partnerId); 1684 chatClient.closeConversation(partnerId); 1685 }, 0)); 1686 } 1687} 1688 1689// Bestehende Route ProfilePhotoController::show() (photo_show), gleiches 1690// URL-Schema wie im alten Chat (chat/js/app.js) und diversen js/in/-Scripts: 1691// /profilephoto/show/{userId}/{slot}/{size}/{cropmode}/{cachebreak}. Groesse 1692// parametrisiert (Default 64 wie bisher) - die Freundschaftsanfrage-/ 1693// Freundschafts-Karten (renderSystemMessage()) brauchen ein groesseres Bild. 1694function avatarUrl(userId, cacheBreak, size = 64) { 1695 return `/profilephoto/show/${userId}/auto/${size}/small/${cacheBreak}`; 1696} 1697 1698// Baut Zeilen (Datums-Trennblasen + Nachrichten) fuer einen zusammenhaengenden, 1699// chronologisch aufsteigenden Ausschnitt einer Nachrichtenliste - gemeinsame 1700// Grundlage fuer den vollen Aufbau (#fullyRebuildRows(), initialLastDateKey=null) 1701// UND die inkrementellen Anhaenge-/Voranstell-Faelle (#appendNewMessageRows()/ 1702// #prependOlderMessageRows()), die jeweils nur einen NEUEN, kleinen Ausschnitt 1703// brauchen statt die komplette bisherige Liste erneut zu durchlaufen 1704// (docs/chat-todo.md #24, Szenario C/E - render() rief das frueher immer mit 1705// ALLEN Nachrichten auf, das kostete bei sehr grossen Konversationen 15-22s pro 1706// neuer Nachricht, siehe verlinkter Lasttest). 1707// 1708// Gibt ein ARRAY von Zeilen-HTML-Strings zurueck (ein Eintrag pro <li>, 1709// Datums-Trennblasen zaehlen als eigene Zeile) statt eines einzigen 1710// zusammengesetzten Strings - VirtualMessageList (core/virtual_message_list.js) 1711// braucht die Zeilen einzeln, um nur die tatsaechlich sichtbaren davon zu 1712// DOM-Knoten zu machen und pro Zeile eine eigene Hoehe zu cachen. 1713// 1714// initialLastDateKey: der dateKeyFor()-Wert der letzten Nachricht VOR diesem 1715// Ausschnitt (null, wenn der Ausschnitt am Anfang einer Konversation steht) - 1716// entscheidet, ob die ERSTE Nachricht dieses Ausschnitts noch eine eigene 1717// Trennblase braucht (anderer Kalendertag als die letzte vorherige) oder nicht. 1718function renderMessageRows(messages, partnerId, partner, readReceiptsMode, me, initialLastDateKey) { 1719 const rows = []; 1720 let lastDateKey = initialLastDateKey; 1721 for (const message of messages) { 1722 const dateKey = dateKeyFor(message.date); 1723 if (dateKey !== lastDateKey) { 1724 rows.push(`<li class="chat-date-separator"><span>${escapeHtml(formatDateLabel(message.date))}</span></li>`); 1725 lastDateKey = dateKey; 1726 } 1727 rows.push(renderMessage(message, partnerId, partner, readReceiptsMode, me)); 1728 } 1729 return rows; 1730} 1731 1732function dateKeyFor(unixSeconds) { 1733 const d = new Date(unixSeconds * 1000); 1734 return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; 1735} 1736 1737function isSameDay(a, b) { 1738 return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); 1739} 1740 1741function formatDateLabel(unixSeconds) { 1742 const d = new Date(unixSeconds * 1000); 1743 const today = new Date(); 1744 if (isSameDay(d, today)) return 'Heute'; 1745 const yesterday = new Date(today); 1746 yesterday.setDate(today.getDate() - 1); 1747 if (isSameDay(d, yesterday)) return 'Gestern'; 1748 return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); 1749} 1750 1751function formatTime(unixSeconds) { 1752 return new Date(unixSeconds * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); 1753} 1754 1755function renderMessage(message, partnerId, partner, readReceiptsMode, me) { 1756 // Spezialnachrichten nach Designstudie (docs/chat-todo.md #40.1, 1757 // for-reference/"Designstudie Spezialnachrichten", Variante 2b): 1758 // eingehend eine zentrierte Ereigniskarte ohne Bubble-Seite, die eigene 1759 // (ausgehende) als blaue Blase rechts mit Symbol + Titel + Unterzeile. 1760 // Die Freundschaftsanfrage im Zustand "befreundet" ist fuer beide Seiten 1761 // dieselbe Karte, Typ 13 (FRIEND_ACCEPT) wird unterdrueckt (siehe 1762 // renderSystemMessage()). 1763 if (SPECIAL_CARD_TYPES.includes(message.type)) { 1764 if (message.type === 13 || (message.type === 6 && partner?.is_friend) || messageDirection(message) === '
1764in') { 1765 return renderSystemMessage(message, partnerId, partner, me, readReceiptsMode); 1766 } 1767 return renderSpecialOutMessage(message, partner, readReceiptsMode); 1768 } 1769 1770 const direction = messageDirection(message); 1771 const outClass = direction === 'out' ? 'chat-message-out' : 'chat-message-in'; 1772 let body = escapeHtml(message.text ?? ''); 1773 // Nur-Emoji-Nachrichten (docs/chat-todo.md #20.8, "wie WhatsApp"): groessere 1774 // Darstellung ohne Bubble-Rahmen bei 1-5 Emojis, nur bei reinem Text 1775 // (Typ 0) - Sondernachrichten/Anhaenge (Bild/Sprachnachricht/Standort/ 1776 // Systemmeldungen) haben immer eine normale Bubble. 1777 let emojiOnlyClass = ''; 1778 if (message.type === 0) { 1779 const emojiCount = getEmojiOnlyCount(message.text ?? ''); 1780 if (emojiCount >= 1 && emojiCount <= 5) { 1781 emojiOnlyClass = ` chat-message-emoji-only chat-message-emoji-count-${emojiCount}`; 1782 } 1783 } 1784 if (message.type === 1 && message.attachment) { 1785 body += `<br><img class="chat-message-image" src="/chat/image/${message.attachment}" alt="Bild">`; 1786 } else if (message.type === 3 && message.attachment) { 1787 // Eigene Buttons statt des nativen <audio controls>-Players 1788 // (docs/chat-todo.md #20.9, Nutzer-Vorgabe "Optik wie WhatsApp") - das 1789 // <audio>-Element bleibt fuer die eigentliche Wiedergabe/Zeitangaben 1790 // bestehen, wird aber per CSS versteckt; #wireVoicePlayers() (in 1791 // render(), nach jedem innerHTML-Rebuild) verdrahtet Play/Pause-Button 1792 // + Fortschrittsbalken dagegen. 1793 body += `<br><span class="chat-voice-player">` 1794 + `<button type="button" class="chat-voice-player-toggle" aria-label="Abspielen">` 1795 + `<svg class="icon chat-icon-on" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#play-arrow"></use></svg>` 1796 + `<svg class="icon chat-icon-off" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#pause"></use></svg>` 1797 + `</button>` 1798 + `<span class="chat-voice-player-track"><span class="chat-voice-player-track-bg"></span><span class="chat-voice-player-progress"></span></span>` 1799 + `<span class="chat-voice-player-time">0:00</span>` 1800 + `<audio class="chat-voice-player-audio hidden" preload="metadata" src="/chat/audio/${message.attachment}"></audio>` 1801 + `</span>`;
1802 } else if (message.type === 5 && message.attachment) { 1803 // Attachment ist "lat,lng" (ChatController::getAttachmentData()). Statisches 1804 // Kartenbild statt reinem Link (docs/chat-todo.md #3) - ueber die chatId 1805 // (ChatController::getStaticMap(), eigener Zugriffsschutz wie bei 1806 // Bild/Audio) statt rohen Koordinaten in der URL. Klick oeffnet weiterhin 1807 // die echte interaktive Karte auf openstreetmap.org. 1808 const [lat, lng] = message.attachment.split(','); 1809 const url = `https://www.openstreetmap.org/?mlat=${encodeURIComponent(lat)}&mlon=${encodeURIComponent(lng)}#map=15/${encodeURIComponent(lat)}/${encodeURIComponent(lng)}`; 1810 body += `<br><a class="chat-message-location" href="${url}" target="_blank" rel="noopener">` 1811 + `<img class="chat-message-map" src="/chat/staticmap/${message.id}" alt="Standort"></a>`; 1812 } else if (message.type === 12) { 1813 // Verpasster Anruf (docs/chat-todo.md #22.3) - eigener Zweig statt der 1814 // generischen Icon-plus-Servertext-Behandlung unten: Text/Icon haengen von 1815 // der Blickrichtung ab (eigener vs. fremder verpasster Anruf), das kann der 1816 // fertige Servertext nicht abbilden. message.text bleibt nur als Server- 1817 // /Log-seitiger Fallback bestehen, wird hier ignoriert. 1818 body = renderMissedCallMessageBody(message, direction); 1819 } else if (message.type === 4) { 1820 // Anruf-Marker (docs/chat-todo.md #16) - bleibt normale 1821 // links/rechts-Bubble mit Icon vor dem Servertext. 1822 // CHAT_MESSAGE_TYPE_CALL_MARKER wird aktuell noch von keiner Stelle im 1823 // Backend erzeugt (nur reservierte Konstante), soll aber bei 1824 // Einfuehrung bereits ein Icon bekommen. 1825 body = renderSpecialMessageIcon() + body; 1826 } 1827 // Verpasster Anruf (12) bekommt kein Lesehaekchen (docs/chat-todo.md #22.3, 1828 // Nutzer-Vorgabe) - anders als bei echten eigenen Nachrichten ist "gelesen" 1829 // hier keine sinnvolle Information (die Karte entsteht bereits als Folge davon, 1830 // dass der Anruf unbeantwortet blieb). 1831 const seenIndicator = (direction === 'out' && message.type !== 12) ? renderSeenIndicator(message, partner, readReceiptsMode) : ''; 1832 body += `<span class="chat-message-time">${formatTime(message.date)}${seenIndicator}</span>`; 1833 return `<li class="chat-message ${outClass}${emojiOnlyClass}">${body}</li>`; 1834} 1835 1836// Erkennt eine Nachricht, die (nach Trimmen) ausschliesslich aus Emoji- 1837// Graphemen besteht, und liefert deren Anzahl (0, wenn nicht reines Emoji 1838// oder leer). Grapheme-genau via Intl.Segmenter statt Array.from/String-Laenge, 1839// da zusammengesetzte Emojis (Familie, Hautton-Modifikatoren, Flaggen) aus 1840// mehreren Codepoints bestehen und sonst als mehrere "Zeichen" gezaehlt 1841// wuerden (identisches Problem/identische Loesung wie im alten Chat, 1842// chat/js/emoji.js#getCodePointName(), dort per Array.from(emoji) pro 1843// einzelnem Emoji statt fuer eine ganze Nachricht). 1844const EMOJI_GRAPHEME_REGEX = /^[\p{Extended_Pictographic}\p{Regional_Indicator}\u{1F3FB}-\u{1F3FF}âï¸]+$/u; 1845 1846function getEmojiOnlyCount(text) { 1847 const trimmed = text.trim(); 1848 if (!trimmed || typeof Intl === 'undefined' || !Intl.Segmenter) return 0; 1849 const graphemes = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(trimmed), (s) => s.segment); 1850 for (const grapheme of graphemes) { 1851 if (!EMOJI_GRAPHEME_REGEX.test(grapheme)) return 0; 1852 } 1853 return graphemes.length; 1854} 1855 1856// Icon vor dem Text des Anruf-Markers (Typ 4) - die uebrigen Spezialtypen 1857// haben seit docs/chat-todo.md #40.1 eigene Karten/Blasen. Dasselbe 1858// "call"-Symbol wie der Anruf-Button im Konversations-Header.
1859function renderSpecialMessageIcon() { 1860 return `<svg class="icon chat-message-special-icon" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#call"></use></svg> `; 1861} 1862 1863// Verpasster Anruf (Typ 12, docs/chat-todo.md #22.3, Nutzer-Vorgabe): eigener 1864// Zweig statt generischem Icon+Servertext, weil Icon UND Text von der 1865// Blickrichtung abhaengen - der Anrufer (direction 'out', hat den Anruf 1866// ausgeloest, siehe ChatController::reportMissedCall()) sieht eine andere 1867// Formulierung als der Angerufene (direction 'in'). message.attachment traegt 1868// seit dieser Aenderung 'video'/'audio' (ChatManager::sendSpecialMessage()- 1869// Payload); fehlt es (Nachrichten von vor dieser Aenderung), gilt das als 1870// Sprachanruf, das bisherige Verhalten. 1871// docs/chat-todo.md #24/#12 (Nutzer-Korrektur): Seiten-Zuordnung der beiden 1872// Varianten war vertauscht. reportMissedCall() wird ausschliesslich vom 1873// ANRUFER gemeldet (ChatController::reportMissedCall(), "Nur diese Seite 1874// meldet das") - auf dessen eigener Chat-Ansicht ist die Nachricht also 1875// direction 'out'. Der Anrufer hat selbst nichts verpasst, sondern nur keine 1876// Antwort bekommen ("Sprachanruf" / "Keine Antwort"); der Angerufene (direction 1877// 'in') merkt erst jetzt, dass ein Anruf verpasst wurde ("Verpasster Anruf"). 1878// Icons phone-outgoing/phone-incoming (mdi, Nutzer-Vorgabe) statt der 1879// vorherigen call-missed(-outgoing)-Symbole. 1880// 1881// Beide Richtungen sind seit Nutzer-Vorgabe (docs/chat-todo.md) zweizeilige 1882// Karten: 'out' zeigt "Sprachanruf/Videoanruf" + "Keine Antwort" (nicht 1883// klickbar - der Anrufer hat gerade erst selbst angerufen, ein erneuter Klick 1884// waere unmotiviert). 'in' zeigt "Verpasster Sprachanruf/Videoanruf" + 1885// "Zum Zurückrufen tippen" und ist klickbar - loest per Klick direkt einen 1886// Rueckruf IM SELBEN FORMAT (Audio/Video, aus message.attachment) aus, 1887// wiederverwendet dafuer denselben Stimulus-Action-Namen/Video-Param wie die 1888// Anruf-Buttons im Konversations-Header (startCall(), _panel.html.twig). 1889// Das Icon des 'out'-Falls ist bewusst NEUTRAL (kein Rot) - ein selbst 1890// ausgeloester, unbeantworteter Anruf braucht keine Signalwirkung/Dringlichkeit 1891// (Nutzer-Vorgabe); nur der tatsaechlich verpasste eingehende Anruf bleibt rot. 1892function renderMissedCallMessageBody(message, direction) { 1893 const isVideo = message.attachment === 'video'; 1894 const kind = isVideo ? 'Videoanruf' : 'Sprachanruf'; 1895 const iconId = direction === 'out' ? 'phone-outgoing' : 'phone-incoming'; 1896 const iconNeutralClass = direction === 'out' ? ' chat-message-special-icon-missed-call-neutral' : ''; 1897 const icon = `<svg class="icon chat-message-special-icon chat-message-special-icon-missed-call${iconNeutralClass}" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#${iconId}"></use></svg>`; 1898 const lines = `<span class="chat-message-missed-call-lines">` 1899 + `<span class="chat-message-missed-call-line">${direction === 'out' ? kind : `Verpasster ${kind}`}</span>` 1900 + `<span class="chat-message-missed-call-line chat-message-missed-call-sub">${direction === 'out' ? 'Keine Antwort' : 'Zum Zurückrufen tippen'}</span>` 1901 + `</span>`;
1902 if (direction === 'out') { 1903 return `<span class="chat-message-missed-call">${icon}${lines}</span>`; 1904 } 1905 return `<button type="button" class="chat-message-missed-call chat-message-missed-call-clickable"` 1906 + ` data-action="click->chat-conversation#startCall" data-chat-conversation-video-param="${isVideo}">` 1907 + `${icon}${lines}</button>`; 1908} 1909 1910function messageDirection(message) { 1911 return message.direction ?? (message.guid && message.id === undefined ? 'out' : message.direction); 1912} 1913 1914// Lesebenachrichtigung (Haekchen), konfigurierbar ueber app.chat.read_receipts_mode 1915// (sites/*/config/cng.yaml, siehe get readReceiptsMode() oben): 'off' -> gar 1916// nicht anzeigen, 'on' -> immer den echten Status, 'friends_only' -> echten 1917// Status nur unter Freunden, sonst ein Fragezeichen-Button (oeffnet 1918// #showSeenInfoPopup()). Nur fuer eigene ('out') Nachrichten relevant. 1919function renderSeenIndicator(message, partner, readReceiptsMode) { 1920 if (readReceiptsMode === 'off') { 1921 return ''; 1922 } 1923 if (readReceiptsMode === 'friends_only' && !partner?.is_friend) { 1924 return '<button type="button" class="chat-message-seen-unknown" title="Warum kein Lesestatus?"' 1925 + ' data-action="click->chat-conversation#showSeenInfoPopup">?</button>'; 1926 } 1927 const read = message.seen === 2; 1928 const label = read ? 'Gelesen' : 'Gesendet'; 1929 // Weiche, runde SVG-Haken statt der harten Unicode-Zeichen (docs/chat-todo.md 1930 // #20.8, Nutzer-Feedback "dicker? / sanfter, nicht so hart") - 1 grauer 1931 // Haken fuer gesendet, 2 gruene Haken fuer gelesen (genau diese Farben laut 1932 // Nutzer-Vorgabe, nicht das WhatsApp-uebliche Blau). stroke-linecap/-linejoin 1933 // "round" erzeugt den weicheren Look, den ein Font-Glyph nicht bieten kann. 1934 const checkPath = read 1935 ? '<path d="M1 8.2L4.6 11.8L11.4 3.2"/><path d="M5.6 8.2L9.2 11.8L16 3.2"/>' 1936 : '<path d="M2 8.2L6.2 12.4L14 3.2"/>';
1937 return `<span class="chat-message-seen${read ? ' chat-message-seen-read' : ''}" title="${label}">` 1938 + `<svg class="chat-message-seen-icon" viewBox="0 0 16 15" fill="none" stroke="currentColor" ` 1939 + `stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${checkPath}</svg>` 1940 + `</span>`; 1941} 1942 1943// Typen, die als Ereigniskarte (eingehend) bzw. Spezial-Blase (ausgehend) 1944// gerendert werden (docs/chat-todo.md #40.1): Freundschaftsanfrage (6), 1945// Geburtstag (7), Profilanfrage (8), Blume (9), Anklopfen (10), Blumen-Dank 1946// (11), Freundschafts-Bestaetigung (13, unterdrueckt). Verpasster Anruf (12) 1947// und Anruf-Marker (4) bleiben normale Bubbles. 1948const SPECIAL_CARD_TYPES = [6, 7, 8, 9, 10, 11, 13]; 1949 1950const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; 1951 1952// Blumen-Payload wie im alten System (FlowerKnockService/ 1953// LegacySpecialMessageMapper): flower (1-8), flowerName, optional 1954// flowerKnockId (fehlt bei importierten Alt-Nachrichten aus der 1955// Shortcode-Aera). null, wenn nicht lesbar. 1956function parseFlowerPayload(message) { 1957 try { 1958 const data = JSON.parse(message.attachment ?? ''); 1959 return data && typeof data === 'object' ? data : null; 1960 } catch (e) { 1961 return null; 1962 } 1963} 1964 1965function flowerImageHtml(data, className) { 1966 const flowerId = Number(data?.flower); 1967 if (!(flowerId >= 1 && flowerId <= 8)) return ''; 1968 // width/height = natuerliche Groesse (400x470) - reserviert per 1969 // aspect-ratio schon vor dem Laden die richtige Hoehe, damit die 1970 // virtualisierte Liste nicht nachmessen muss. 1971 return `<img class="${className}" src="/images/flowers/${flowerId}.png" width="400" height="470" alt="${escapeHtml(data.flowerName ?? 'Blume')}">`; 1972} 1973 1974// Gemeinsames Geruest der eingehenden Ereigniskarte (Designstudie 2b): 1975// optional rundes Profilbild, das oben ueber den Kartenrand ragt, optional 1976// eine vollbreite Bildflaeche oben, darunter Label/Titel/Text, Zusatzinhalt 1977// (z.B. Fortschrittsbalken) und Pill-Buttons. 1978function renderEventCard(message, { avatarHtml = '', mediaHtml = '', label = '', title = '', text = '', extraHtml = '', actionsHtml = '', modifier = '' }) { 1979 const classes = ['chat-message', 'chat-message-system', 'chat-event-card']; 1980 if (avatarHtml) classes.push('chat-event-card-has-avatar'); 1981 if (modifier) classes.push(modifier); 1982 let html = `<li class="${classes.join(' ')}">${avatarHtml}${mediaHtml}<div class="chat-event-card-body">`; 1983 if (label) html += `<div class="chat-event-card-label">${label}</div>`; 1984 if (title) html += `<div class="chat-event-card-title">${title}</div>`; 1985 if (text) html += `<div class="chat-event-card-text">${text}</div>`; 1986 html += extraHtml; 1987 if (actionsHtml) html += `<div class="chat-event-card-actions">${actionsHtml}</div>`; 1988 html += `<span class="chat-message-time">${formatTime(message.date)}</span></div></li>`; 1989 return html; 1990} 1991 1992function eventCardAvatar(userId, cacheBreak) { 1993 if (userId == null) return ''; 1994 return `<img class="chat-event-card-avatar" src="${avatarUrl(userId, cacheBreak, 120)}" width="64" height="64" alt="">`; 1995} 1996 1997// Antworten-Button: fokussiert das Eingabefeld (optional mit vorbelegtem 1998// Text, nur wenn das Feld noch leer ist) - siehe focusReply() im Controller. 1999function replyButton(label, primary, replyText = '') { 2000 const textAttr = replyText ? ` data-reply-text="${escapeHtml(replyText)}"` : ''; 2001 return `<button type="button" class="chat-event-btn${primary ? ' chat-event-btn-primary' : ''}" data-action="click->chat-conversation#focusReply"${textAttr}>${label}</button>`; 2002} 2003 2004function linkButton(label, href, primary) { 2005 return `<a class="chat-event-btn${primary ? ' chat-event-btn-primary' : ''}" href="${href}">${label}</a>`; 2006} 2007 2008// Eingehende Spezialnachrichten + Freundschafts-Zustand "befreundet" als 2009// zentrierte Ereigniskarte (docs/chat-todo.md #40.1). Die Servertexte 2010// (message.text) sind in der Ich-Form des Absenders formuliert ("Ich 2011// gratuliere dir ...") - ohne Bubble-Seite nicht mehr eindeutig, deshalb 2012// eigene Texte je Typ; message.text nur noch als Fallback.
2013function renderSystemMessage(message, partnerId, partner, me, readReceiptsMode = 'on') { 2014 const partnerName = escapeHtml(partner?.username ?? 'Jemand'); 2015 // Bei geloeschten Partnern (docs/chat-todo.md §43 T7) Platzhalter statt Profilbild. 2016 const partnerAvatar = !partner ? '' : (partner.is_deleted 2017 ? `<img class="chat-event-card-avatar" src="${deletedAvatarUrl()}" width="64" height="64" alt="">` 2018 : eventCardAvatar(partnerId, partner.picture_cache_break)); 2019 2020 switch (message.type) { 2021 case 13: 2022 // FRIEND_ACCEPT: FriendsController::acceptRequest() legt sie nur 2023 // beim urspruenglich Anfragenden an, mit fest eingesetztem Namen 2024 // des Annehmenden - die Typ-6-Karte uebernimmt ihre Funktion 2025 // korrekt personalisiert (Nutzer-Vorgabe 2026-09-16, #20.21). 2026 return ''; 2027 case 6: 2028 return renderFriendRequestCard(message, partnerId, partner, me, readReceiptsMode); 2029 case 7: 2030 return renderBirthdayCard(message, partnerName, me); 2031 case 8: 2032 return renderProfileRequestCard(message, partnerName, partnerAvatar, me); 2033 case 9: 2034 return renderFlowerCard(message, partnerName); 2035 case 10: 2036 return renderEventCard(message, { 2037 modifier: 'chat-event-card-knock', 2038 mediaHtml: `<div class="chat-event-card-banner"><div class="chat-event-card-banner-title">Klopf, klopf!</div>` 2039 + `<div class="chat-event-card-banner-sub">${partnerName} hat bei dir angeklopft</div></div>`, 2040 text: `${partnerName} würde dich gerne näher kennenlernen.`, 2041 actionsHtml: (partner?.username && !partner.is_deleted ? linkButton('Profil ansehen', `/profil/${encodeURIComponent(partner.username)}/`, false) + replyButton('Antworten', true) : ''), 2042 }); 2043 case 11: 2044 // Blumen-Dank - im alten System reine Textnachricht mit 2045 // Aufforderung, "ein paar persoenliche Zeilen" zu schreiben 2046 // (templates/messages/types/flower_thanks.html.twig). 2047 return renderEventCard(message, { 2048 avatarHtml: partnerAvatar, 2049 label: 'Dankeschön', 2050 text: `${partnerName} bedankt sich bei dir für die Blume. Schreib doch ein paar persönliche Zeilen zurück.`, 2051 actionsHtml: replyButton('Nachricht schreiben', true), 2052 }); 2053 default: 2054 return renderEventCard(message, { text: escapeHtml(message.text ?? '') }); 2055 } 2056} 2057 2058// Freundschaftsanfrage (Typ 6) ist die EINZIGE dauerhaft sichtbare Karte fuer 2059// den kompletten Freundschafts-Lebenszyklus (Nutzer-Vorgabe 2026-09-16: 2060// "eine Nachricht, verschiedene Varianten der Darstellung: Frage, befreundet 2061// mit A, befreundet mit B"). Gerendert aus dem AKTUELLEN partner.is_friend 2062// statt aus message.text. Hier nur die Empfaenger-Sicht ("Frage", mit 2063// Annehmen/Ablehnen) und der fuer beide Seiten gleiche "befreundet"-Zustand - 2064// die Absender-Sicht einer offenen/abgelehnten Anfrage ist eine blaue Blase 2065// (renderSpecialOutMessage()). "Noch nicht entschieden" wird bewusst nicht 2066// serverseitig geprueft (wie in der App auch nicht), kein eigener
2067// "abgelehnt"-Zustand gespeichert, siehe #answerFriendRequest(). 2068function renderFriendRequestCard(message, partnerId, partner, me, readReceiptsMode = 'on') { 2069 if (partner?.is_friend) { 2070 const avatars = `<div class="chat-event-card-avatars">` 2071 + (me?.id != null ? `<img class="chat-event-card-avatar" src="${avatarUrl(me.id, me.cacheBreak, 120)}" width="64" height="64" alt="">` : '') 2072 + `<img class="chat-event-card-avatar" src="${avatarUrl(partnerId, partner.picture_cache_break, 120)}" width="64" height="64" alt="">` 2073 + `</div>`; 2074 return renderEventCard(message, { 2075 avatarHtml: avatars, 2076 label: 'Freundschaft', 2077 text: `Du bist nun mit ${escapeHtml(partner.username ?? 'diesem Nutzer')} befreundet.`, 2078 // Was die Freundschaft freischaltet (Nutzer-Vorgabe 2026-10-01) - Anrufe immer 2079 // (AV-Chat ist freundschaftsgebunden), den Gelesen-Status nur, wenn er laut 2080 // app.chat.read_receipts_mode erst unter Freunden sichtbar ist. 2081 extraHtml: `<div class="chat-event-card-hint">Ihr könnt jetzt per Video oder Sprache miteinander telefonieren${ 2082 readReceiptsMode === 'friends_only' ? ' und seht, ob eure Nachrichten gelesen wurden' : ''}.</div>`, 2083 }); 2084 } 2085 // message.text kommt vom Server bereits aus Empfaenger-Perspektive 2086 // (FriendsController::sendRequest(): "<Name> möchte dich als Freund 2087 // hinzufügen."). 2088 return renderEventCard(message, { 2089 avatarHtml: partner ? eventCardAvatar(partnerId, partner.picture_cache_break) : '', 2090 label: 'Freundschaftsanfrage', 2091 text: escapeHtml(message.text ?? ''), 2092 actionsHtml: `<span class="chat-event-card-actions-group" data-friend-request-actions>` 2093 + `<button type="button" class="chat-event-btn" data-action="click->chat-conversation#declineFriendRequest" data-partner-id="${partnerId}">Ablehnen</button>` 2094 + `<button type="button" class="chat-event-btn chat-event-btn-primary" data-action="click->chat-conversation#acceptFriendRequest" data-partner-id="${partnerId}">Annehmen</button>` 2095 + `</span>`, 2096 }); 2097} 2098 2099// Geburtstag (Typ 7): Muffin-Grafik aus dem alten Nachrichtensystem 2100// (templates/messages/types/birthday.html.twig, 1920x962) vollbreit oben. 2101// "Danke sagen" belegt nur das Eingabefeld vor (kein eigener Endpunkt, im 2102// alten System gab es keine Aktion). 2103function renderBirthdayCard(message, partnerName, me) { 2104 const date = new Date(message.date * 1000); 2105 const myName = me?.username ? `, <span class="chat-event-card-accent">${escapeHtml(me.username)}</span>` : ''; 2106 return renderEventCard(message, { 2107 modifier: 'chat-event-card-birthday', 2108 mediaHtml: `<img class="chat-event-card-media-image" src="/images/muffin.jpg" width="1920" height="962" alt="Muffins mit Kerzen">`, 2109 label: `<span class="chat-event-card-accent">${date.getDate()}. ${MONTH_NAMES[date.getMonth()]} · Dein Geburtstag</span>`, 2110 title: `Alles Gute${myName}!`, 2111 text: `${partnerName} gratuliert dir herzlich zum Geburtstag!`, 2112 actionsHtml: replyButton('Danke sagen', true, 'Vielen Dank für die Glückwünsche! ð'), 2113 }); 2114} 2115 2116// Blume (Typ 9): Blumengrafik aus dem alten Nachrichtensystem 2117// (/images/flowers/<1-8>.png, freigestellt) auf einer hellen Flaeche in der 2118// Primaerfarbe. Bedanken-Button wie im alten System nur, wenn 2119// flowerKnockId bekannt ist (FlowerKnockController::flowerThanks()). 2120function renderFlowerCard(message, partnerName) { 2121 const data = parseFlowerPayload(message); 2122 const flowerName = data?.flowerName ? escapeHtml(data.flowerName) : null; 2123 const image = flowerImageHtml(data, 'chat-event-card-flower'); 2124 return renderEventCard(message, { 2125 modifier: 'chat-event-card-flower-card', 2126 mediaHtml: image ? `<div class="chat-event-card-banner">${image}</div>` : '', 2127 title: flowerName ? `Eine ${flowerName} für dich` : 'Eine Blume für dich', 2128 text: data 2129 ? `${partnerName} hat dir eine ${flowerName ?? 'Blume'} geschickt und signalisiert dir damit Interesse.` 2130 : escapeHtml(message.text ?? ''), 2131 actionsHtml: data?.flowerKnockId 2132 ? `<button type="button" class="chat-event-btn chat-event-btn-primary" data-action="click->
2132chat-conversation#thankForFlower" data-flower-knock-id="${escapeHtml(String(data.flowerKnockId))}">Jetzt bedanken</button>` 2133 : '', 2134 }); 2135} 2136 2137// Profilanfrage (Typ 8, ProfileRequestService, message.attachment = 2138// 'photo'/'questions'/'hobbies'). Foto: Platzhalter-Kreis mit Kamera bzw. 2139// "erledigt" mit eigenem Bild, sobald eins hochgeladen ist. Fragen/Hobbys: 2140// Fortschrittsbalken wie Designstudie 2a (Ausfuellstand aus 2141// me.profileStatus, per /chat/profile-status nachgeladen). Hobbys werden 2142// bewusst nicht im Chat ausgefuellt (zu viele zur Auswahl) - Verweis auf 2143// profile-edit, Reiter per ?tab= vorgewaehlt. 2144function renderProfileRequestCard(message, partnerName, partnerAvatar, me) { 2145 const status = me?.profileStatus ?? null; 2146 const label = `Wunsch von ${partnerName}`; 2147 if (message.attachment === 'photo') { 2148 const done = (status?.photo_count ?? 0) > 0; 2149 const slot = done && me?.id != null 2150 ? `<span class="chat-event-card-photo-slot chat-event-card-photo-slot-done"><img src="${avatarUrl(me.id, me.cacheBreak, 120)}" width="72" height="72" alt=""><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg></span>` 2151 : `<a class="chat-event-card-photo-slot" href="/profilephoto/" aria-label="Profilbild hochladen"><svg class="icon" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#photo"></use></svg></a>`; 2152 return renderEventCard(message, { 2153 avatarHtml: partnerAvatar, 2154 label, 2155 title: 'Zeig dich!', 2156 text: `${partnerName} würde dich gern sehen und bittet um ein Profilbild.`, 2157 extraHtml: slot + (done ? '<div class="chat-event-card-done">Erledigt â du hast inzwischen ein Profilbild.</div>' : ''), 2158 actionsHtml: done ? '' : linkButton('Profilbild hochladen', '/profilephoto/', true), 2159 }); 2160 } 2161 const isHobbies = message.attachment === 'hobbies'; 2162 if (!isHobbies && message.attachment !== 'questions') { 2163 return renderEventCard(message, { avatarHtml: partnerAvatar, label, text: escapeHtml(message.text ?? '') }); 2164 } 2165 return renderEventCard(message, { 2166 avatarHtml: partnerAvatar, 2167 label, 2168 title: isHobbies ? 'Was machst du gern?' : 'Erzähl mehr von dir', 2169 text: isHobbies 2170 ? `${partnerName} interessiert sich für deine Hobbys und Interessen.` 2171 : `${partnerName} möchte dich besser kennenlernen und würde gern deine Antworten auf die persönlichen Fragen lesen.`, 2172 extraHtml: renderProfileProgress(status, isHobbies ? 'hobbies' : 'questions'), 2173 actionsHtml: linkButton(isHobbies ? 'Hobbys eintragen' : 'Profil vervollständigen', 2174 `/profile-edit/?tab=${isHobbies ? 'profile_details' : 'profile_questions'}`, true), 2175 }); 2176} 2177 2178// Fortschrittsbalken "Dein Profil xx %" + "Noch offen: ..." (Designstudie 2a). 2179// Vor dem Laden (status null) mit gleicher Hoehe als Platzhalter, damit sich 2180// die Zeilenhoehe beim Nachladen nicht aendert. Der angefragte Bereich steht 2181// bei "Noch offen" vorne. 2182function renderProfileProgress(status, focus) { 2183 const percent = status ? Math.max(0, Math.min(100, Number(status.overall) || 0)) : 0; 2184 let open = []; 2185 if (status) { 2186 const hobbies = { key: 'hobbies', label: 'Hobbys', missing: (status.hobbies_filled ?? 0) < 3 }; 2187 const questions = { key: 'questions', label: 'Persönliche Fragen', missing: status.extra < 100 }; 2188 open = [ 2189 ...(focus === 'hobbies' ? [hobbies, questions] : [questions, hobbies]), 2190 { label: 'Fotos', missing: status.photos < 100 }, 2191 { label: 'Allgemeines', missing: status.general < 100 }, 2192 { label: 'Ich suche', missing: status.lookingfor < 100 }, 2193 ].filter((item) => item.missing).map((item) => item.label).slice(0, 3); 2194 } 2195 let openText = ' '; 2196 if (status) { 2197 openText = open.length ? `Noch offen: ${open.join(' · ')}` : 'Dein Profil ist vollständig ausgefüllt â danke!'; 2198 } 2199 return `<div class="chat-event-card-progress">` 2200 + `<div class="chat-event-card-progress-head"><span>Dein Profil</span><strong>${status ? `${percent} %` : 'â¦'}</strong></div>` 2201 + `<div class="chat-event-card-progress-track"><span class="chat-event-card-progress-bar chat-event-card-progress-${Math.round(percent / 5) * 5}"></span></div>` 2202 + `<div class="chat-event-card-progress-open">${openText}</div>` 2203 + `</div>`; 2204} 2205 2206// Eigene (ausgehende) Spezialnachricht als blaue Blase rechts 2207// (Designstudie 2b "Du hast angeklopft"): rundes Symbol + Titel + 2208// Unterzeile, bei Blumen die Grafik darueber. Zeit/Lesehaken wie bei 2209// normalen eigenen Nachrichten.
2210function renderSpecialOutMessage(message, partner, readReceiptsMode) { 2211 const partnerName = escapeHtml(partner?.username ?? 'diesem Nutzer'); 2212 const icon = (id) => `<svg class="icon" aria-hidden="true"><use xlink:href="${iconSpriteUrl()}#${id}"></use></svg>`; 2213 let iconHtml = ''; 2214 let title = ''; 2215 let sub = ''; 2216 switch (message.type) { 2217 case 6: 2218 // Nutzer-Vorgabe 2026-09-16: offene und abgelehnte Anfrage 2219 // einheitlich "Du hast eine Freundschaftsanfrage gesendet." 2220 iconHtml = icon('friend-add'); 2221 title = 'Du hast eine Freundschaftsanfrage gesendet.'; 2222 break; 2223 case 7: 2224 // Kuchen-Symbol wie bei den anderen statt des Muffin-Bilds (Nutzer-Vorgabe 2026-10-01). 2225 iconHtml = icon('birthday'); 2226 title = 'Du hast gratuliert'; 2227 sub = `Geburtstagsgruà an ${partnerName}`; 2228 break; 2229 case 8: 2230 iconHtml = icon({ photo: 'photo', hobbies: 'intentions' }[message.attachment] ?? 'pencil'); 2231 title = { photo: 'Profilbild gewünscht', hobbies: 'Hobbys gewünscht', questions: 'Profilangaben gewünscht' }[message.attachment] ?? 'Profilanfrage gesendet'; 2232 sub = `Deine Bitte an ${partnerName}`; 2233 break; 2234 case 9: { 2235 // Ohne die Blumengrafik - die sieht nur der Empfaenger (Nutzer-Vorgabe 2026-10-01). 2236 const data = parseFlowerPayload(message); 2237 iconHtml = icon('flower'); 2238 title = `Du hast eine ${data?.flowerName ? escapeHtml(data.flowerName) : 'Blume'} verschenkt`; 2239 sub = `an ${partnerName}`; 2240 break; 2241 } 2242 case 10: 2243 iconHtml = icon('door-knocker'); 2244 title = 'Du hast angeklopft'; 2245 sub = `bei ${partnerName}`; 2246 break; 2247 case 11: 2248 iconHtml = icon('flower'); 2249 title = 'Du hast dich bedankt'; 2250 sub = `für die Blume von ${partnerName}`; 2251 break; 2252 default: 2253 title = escapeHtml(message.text ?? ''); 2254 } 2255 const body = `<span class="chat-special-out-row">` 2256 + (iconHtml ? `<span class="chat-special-out-icon">${iconHtml}</span>` : '') 2257 + `<span class="chat-special-out-lines"><span class="chat-special-out-title">${title}</span>` 2258 + (sub ? `<span class="chat-special-out-sub">${sub}</span>` : '') 2259 + `</span></span>` 2260 + `<span class="chat-message-time">${formatTime(message.date)}${renderSeenIndicator(message, partner, readReceiptsMode)}</span>`; 2261 return `<li class="chat-message chat-message-out chat-special-out">${body}</li>`; 2262} 2263 2264function escapeHtml(text) { 2265 const div = document.createElement('div'); 2266 div.textContent = text; 2267 return div.innerHTML; 2268} 2269 2270// "bis 14:30" (heute), "bis Di., 30.09., 14:30" oder "dauerhaft" - fuer den Menuepunkt 2271// "Stummschaltung aufheben" (docs/chat-todo.md §39). 2272function formatMutedUntil(partner) { 2273 if (partner?.muted_forever) return 'dauerhaft'; 2274 if (typeof partner?.muted_until !== 'number') return ''; 2275 const until = new Date(partner.muted_until * 1000); 2276 const time = until.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); 2277 if (until.toDateString() === new Date().toDateString()) return `bis ${time}`; 2278 return `bis ${until.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}, ${time}`; 2279}
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.