PageSourceSearch

https://www.40gold.de/assets/scripts/chat/controllers/chat_conversation_controller-VvqRUam.js

js 40gold.de collected 2026-10-04 03:32:55 UTC 125,489 bytes, 2,279 lines download raw bytes

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 = '&nbsp;';
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.