PageSourceSearch

https://morfoz.ai/public/chat-flow.js?v=1790769371

js morfoz.ai collected 2026-10-05 15:08:24 UTC 313,963 bytes, 6,237 lines download raw bytes

1// Real chat: replaces the prototype's fakeSend (hardcoded replies) with a
2// live POST /api/conversations/messages call to Claude Sonnet 4.6.
3//
4// Features wired here:
5//   - Butterfly typing indicator (iframe → /animation/typing-butterfly.html) while waiting
6//   - Reliable auto-scroll to bottom on new messages (rAF + MutationObserver)
7//   - Disabled send button during in-flight request
8//   - Per-message conformity score pill auto-rendered (inline-training hook in panel-app.js handles it)
9//
10// Cleanly disables the prototype's fakeSend by cloning the composer + send
11// elements (which removes their existing listeners), then re-binding them
12// to our handler.
13
14import { api, SEKME_KIMLIGI, canliAyar, escapeHtml, linkifyHtml, linkifyText, resmiDegilUyarisiGerekli, toast, reportClientError } from './api-client.js';
15import { appendMessage as cacheAppendMessage, upsertConversation } from './morfoz-cache.js';
16import { t, tHepsi, currentLang, onLanguageChange } from './i18n.js';
17// NOT: t(anahtar) || 'yedek' biçimini YAZMA — ÖLÜ KOD. t() eksik anahtarda
18// anahtarın KENDİSİNİ döndürüyor (boş değil), yani || hiçbir zaman devreye
19// girmiyor. Eksik çeviri, yedeği değil ham anahtarı gösterir; çare anahtarı
20// sözlüğe eklemektir.
21// (Bu notta örnek anahtar TIRNAK İÇİNDE yazılmıyor: sözlük denetimi
22//  tests/test_i18n.py kodu statik tarıyor ve yorumdaki uydurma anahtarı
23//  "sözlükte yok" diye raporluyordu.)
24import { renderAvatar, AVATAR_PRESETS } from './avatar-presets.js';
25import { openCallUI } from './call-ui.js';
26import { openGercekZamanli, gercekZamanliDurumu } from './gercek-zamanli-gorusme.js';
27import {
28  initChatActions, decorateRow, onLiveEvent as onActionEvent,
29  pendingReplyId, clearReply, resetForConversation, openStarredPanel,
30} from './chat-actions.js';
31// Başlıktaki durum satırı (çevrim içi / son görülme / N üye / "yazıyor…") ve
32// okundu tikleri. Sohbet DEĞİŞİMİNİ yalnız burası bildiği için kurulumu bu
33// dosya tetikler.
34import * as presence from './chat-presence.js';
35import { emojiSeciciBagla } from './emoji-picker.js';
36import { yardimDugmesiHtml } from './yardim-penceresi.js';
37// Engelle / şikâyet / tanımadık kişi şeridi / mesaj dolandırıcılık etiketi.
38// Çizim ve sunucu çağrıları o kütüphanede; burada yalnız bağlanma noktaları.
39import {
40  dmGuvenligiYukle, yabanciSeridiniKaldir, mesajRiskEtiketiEkle,
41  guvenlikOzeti, dmGuvenlikBolumuHtml, dmGuvenlikBolumuBagla,
42} from './guvenlik-ui.js';
43
44const state = {
45  conversationId: null,
46  activeAssistantId: 1,  // Morfoz Helper by default (chat view's seeded assistant)
47  inFlight: false,
48  // WhatsApp-style cursor pagination
49  oldestLoadedId: null,    // smallest message.id currently in DOM
50  hasMoreOlder: true,      // false once an older-page returns less than PAGE_SIZE
51  loadingOlder: false,     // guard so the scroll listener doesn't fire concurrently
52  // Eski sayfa yukarı eklenirken kaydırma gözlemcisini susturur: o ekleme
53  // "yeni mesaj geldi" değil, kullanıcının okuduğu geçmiştir.
54  konumOnariliyor: false,
55};
56const PAGE_SIZE = 20;
57
58export function initChatFlow() {
59  injectAttachmentStyles();
60  _cevapEkiKancasi();
61  const sendBtn = document.getElementById('sendBtn');
62  const composer = document.querySelector('.composer-input');
63  const chatInner = document.querySelector('#chatView .chat-inner');
64  if (!sendBtn || !composer || !chatInner) return;
65
66  // Wipe prototype's existing listeners by replacing the nodes with clones
67  const newSendBtn = sendBtn.cloneNode(true);
68  sendBtn.parentNode.replaceChild(newSendBtn, sendBtn);
69  const newComposer = composer.cloneNode(true);
70  composer.parentNode.replaceChild(newComposer, composer);
71
72  // Re-wire the textarea autosize + send button enable/disable behavior
73  newComposer.addEventListener('input', () => {
74    newComposer.style.height = 'auto';
75    newComposer.style.height = Math.min(newComposer.scrollHeight, 140) + 'px';
76    newSendBtn.disabled = newComposer.value.trim().length === 0;
77    // @bahsetme tamamlaması — hata YUTULUYOR, yazmayı engellemesin.
78    _bahsetmeGuncelle(newComposer).catch(() => {});
79  });
80  newComposer.addEventListener('blur', () => setTimeout(_bahsetmeKapat, 120));
81
82  // Re-bind send + Enter
83  newSendBtn.addEventListener('click', () => realSend(newComposer, newSendBtn, chatInner));
84  newComposer.addEventListener('keydown', (e) => {
85    // Tamamlama listesi AÇIKSA klavye ÖNCE ona ait: Enter mesajı göndermek
86    // yerine seçili kişiyi yazmalı, yoksa liste klavyeyle hiç kullanılamazdı.
87    if (_bahsetmeTus(e)) return;
88    if (e.key === 'Enter' && !e.shiftKey) {
89      e.preventDefault();
90      if (!newSendBtn.disabled && !state.inFlight) realSend(newComposer, newSendBtn, chatInner);
91    }
92  });
93
94  // Kaydırma kabı SÖZLEŞMEYLE bulunur, ölçümle değil.
95  //
96  // BURASI UZUN SÜREN BİR HATANIN KAYNAĞIYDI. `findScrollableAncestor` bir
97  // elemanın gerçekten taşıp taşmadığına bakıyor (`scrollHeight > clientHeight`).
98  // Ama `initChatFlow` panel açılışında çalışıyor (panel-app.js) ve o anda
99  // `#chatView` `.view { display:none }` ile GİZLİ — gizli elemanın iki ölçüsü de
100  // 0, dolayısıyla arama hep `null` dönüyor ve `scrollEl` hiç kaydırmayan
101  // `.chat-inner`a düşüyordu. Sonuçları:
102  //   • dipteMi() hep "dipte" diyordu → "kullanıcı yukarıda, rahat bırak"
103  //     koruması HİÇ çalışmıyordu (kullanıcı okurken ekran dibe fırlıyordu),
104  //   • "En alta in" düğmesi `.chat-inner`a ekleniyor, `chatInner.innerHTML=''`
105  //     her sohbet açılışında onu siliyordu → düğme hiç görünmüyordu,
106  //   • yukarı kaydırınca eski sayfa yükleme de yanlış elemana bağlanıyordu.
107  // `closest` yerleşim ölçmez; gizliyken de doğru elemanı verir.
108  const scrollEl = chatInner.closest('.chat')
109    || findScrollableAncestor(chatInner) || chatInner;
110
111  // WhatsApp kuralı: kullanıcı geçmişi okumak için yukarı çıktıysa YERİNDEN ETME.
112  //
113  // ÖNCEDEN gözlemci her DOM değişiminde koşulsuz dibe kaydırıyordu. İki gerçek
114  // hata üretiyordu: (1) geçmişi okurken düşen bir mesaj kullanıcıyı aşağı
115  // fırlatıyordu, (2) `loadOlderPage` konumu özenle koruyor (Δ kadar geri
116  // alıyor) ama gözlemcinin 600 ms'ye yayılan gecikmeli kaydırmaları hemen
117  // üstüne yazıyordu — eski sayfa yükleniyor, ekran anında dibe dönüyor,
118  // kullanıcı yüklediği mesajları hiç göremiyordu.
119  const DIP_ESIGI = 140;   // px — bu kadar yakınsa "dipte" sayılır
120  const dipteMi = () =>
121    scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight <= DIP_ESIGI;
122
123  // "Kullanıcı dipte MİYDİ" sorusunun cevabı, YENİ SATIR EKLENMEDEN ÖNCEKİ
124  // duruma ait olmalı. `dipteMi()`yi ekleme sonrasında çağırmak sessiz bir hata
125  // üretiyordu: yeni satır scrollHeight'i kendi yüksekliği kadar büyütür, bu da
126  // 140 px'lik eşiği anında aşar — yani DİPTE DURAN kullanıcı, uzun bir mesaj
127  // geldiğinde "yukarıda okuyor" sanılıp takip iptal ediliyor ve üstüne sahte
128  // bir okunmamış rozeti yanıyordu.
129  //
130  // İçerik büyümesi `scroll` olayı ÜRETMEZ; bu yüzden yalnızca gerçek kaydırma
131  // anlarında tazelenen bu bayrak, doğal olarak "ekleme öncesi" cevabı tutar.
132  let sonDipte = true;
133
134  const jumpBtn = enAltaInDugmesiKur(scrollEl, () => scrollToBottom(true));
135  let jumpRaf = 0;
136  // Yukarıdayken kaçırılan mesaj sayısı — düğmenin üstündeki rozet.
137  let okunmamisAlt = 0;
138  const jumpRozet = jumpBtn.querySelector('.chat-jump-badge');
139  const jumpDurumGuncelle = () => {
140    const dipte = dipteMi();
141    if (dipte) okunmamisAlt = 0;          // dibe indiysen hepsini gördün
142    if (jumpRozet) {
143      jumpRozet.textContent = okunmamisAlt > 9 ? '9+' : String(okunmamisAlt);
144      jumpRozet.style.display = okunmamisAlt ? '' : 'none';
145    }
146    jumpBtn.classList.toggle('visible', !dipte);
147  };
148
149  // Kullanıcı kaydırma kuyruğunun ortasında tekerleğe/parmağa dokunursa bekleyen
150  // yazımlar İPTAL olur. Aşağıdaki kuyruk 600 ms'ye yayılıyor; bayrak olmasaydı
151  // kullanıcı yukarı çıkmaya başladığı anda gecikmeli yazımlar onu geri aşağı
152  // çekiyordu ("ekran beni tutmuyor").
153  let kullaniciAraya = false;
154  ['wheel', 'touchstart', 'keydown'].forEach(ev =>
155    scrollEl.addEventListener(ev, () => { kullaniciAraya = true; }, { passive: true }));
156
157  const scrollToBottom = (zorla = true) => {
158    // `zorla=false` = "dipteysen takip et, değilsen dokunma". Açık eylemler
159    // (kendi mesajını gönderme, sohbeti ilk açma) `zorla=true` ile gelir —
160    // orada kullanıcının niyeti belli.
161    if (!zorla && !sonDipte) { jumpDurumGuncelle(); return; }
162    kullaniciAraya = false;
163    const doScroll = () => {
164      if (kullaniciAraya) return;                        // kullanıcı devraldı
165      scrollEl.scrollTop = scrollEl.scrollHeight;        // TEK gerçek kaydırıcı
166      sonDipte = true;                                   // programatik iniş de "dipte"
167      jumpDurumGuncelle();
168    };
169    doScroll();                                          // immediate
170    requestAnimationFrame(doScroll);                     // after layout
171    setTimeout(doScroll, 60);                            // after first paint
172    setTimeout(doScroll, 250);                           // after iframe load / font swap
173    setTimeout(doScroll, 600);                           // after markdown render settles
174  };
175
176  // Yalnız YENİ MESAJ SATIRI kaydırma sebebidir. Önceden `characterData` ve her
177  // alt-ağaç değişimi de tetikliyordu: "Demleniyor…" yazan bekleme etiketi,
178  // tepki çipleri ve alıntı blokları saniyede birkaç kez kaydırma çağırıyordu.
179  // `konumOnariliyor` bayrağı eski sayfa yukarı eklenirken gözlemciyi susturur.
180  new MutationObserver((kayitlar) => {
181    if (state.konumOnariliyor) return;
182    const yeniSatir = kayitlar.some(m => m.type === 'childList'
183      && [...m.addedNodes].some(n => n.nodeType === 1 && n.classList?.contains('msg-row')));
184    if (yeniSatir) scrollToBottom(false);
185  }).observe(chatInner, { childList: true, subtree: true });
186
187  // Düğmenin görünürlüğü kaydırmayı izler. rAF kısıtlaması: `scroll` saniyede
188  // onlarca kez gelir, her birinde scrollHeight okumak yerleşimi zorlar.
189  scrollEl.addEventListener('scroll', () => {
190    if (jumpRaf) return;
191    jumpRaf = requestAnimationFrame(() => {
192      jumpRaf = 0; sonDipte = dipteMi(); jumpDurumGuncelle();
193    });
194  }, { passive: true });
195
196  // Balon üstü eylemler (yanıtla / tepki / ilet / paylaş / yıldız / sabitle /
197  // düzenle / sil). Eskiden burada YALNIZ sağ-tık-sil vardı; tamamı artık
198  // chat-actions.js'te — sağ tık, uzun basma ve "⋯" düğmesi aynı menüyü açar.
199  initChatActions({ chatInner, composer: newComposer, sendBtn: newSendBtn, state,
200                    guvenlikDegisti: () => _guvenlikDegisti(state.activeConvInfo) });
201  wireStarredMenu();
202
203  // Sohbet AÇILIŞINDA dibe inmeyi renderFirstPage zaten zorluyor. Buradaki
204  // zorlama mükerrerdi ve zarar veriyordu: bu düğmeye program da tıklıyor
205  // (hash-route.js, chat-list.js), yani bildirimden sohbete dönen kullanıcı
206  // okuduğu yerden koparılıyordu. Şimdi yalnız "dipteysen takip et".
207  document.querySelector('.rail-btn[data-nav="chats"]')?.addEventListener('click', () => {
208    setTimeout(() => scrollToBottom(false), 100);
209  });
210  setTimeout(() => scrollToBottom(false), 300);
211
212  // Expose so realSend can call it directly after each step
213  state.scrollToBottom = scrollToBottom;
214  // Konum farkındalığı dışarıya: setActiveConversation'daki önbellek tazelemesi
215  // "kullanıcı yukarıda okuyor mu" sorusunu sorabilsin.
216  state.dipteMi = dipteMi;
217  // Alıntı/sabit/yıldızlı mesaja gitme chat-actions'ta ama SAYFALAMA burada:
218  // "şu mesaj görünene kadar geçmişi yükle" hizmetini oraya böyle veriyoruz.
219  state.mesajaKadarYukle = mesajaKadarYukle;
220  // Yukarıdayken kaçırılan mesajı düğmedeki rozete işle.
221  state.yeniMesajIsareti = () => {
222    if (!sonDipte) { okunmamisAlt++; jumpDurumGuncelle(); }
223  };
224
225  // WhatsApp-style: when user scrolls near the top, load older messages
226  hookOlderScroll(scrollEl);
227
228  // Expose for other modules (e.g., inline training reads active assistant_id)
229  window.morfozActiveAssistantId = state.activeAssistantId;
230
231  // ---- Paperclip: file attachment ----
232  wireAttachButton(newComposer, newSendBtn, chatInner);
233  // ---- Sohbete dosya sürükle-bırak (ataçla aynı önizleme/onay kapısından geçer) ----
234  wireDragDrop(newComposer, newSendBtn, chatInner);
235  // ---- Emoji picker ----
236  wireEmojiButton(newComposer, newSendBtn);
237  // ---- Avatar show/hide toggle ----
238  wireAvatarToggle();
239  // ---- Görsel eklerine tıklayınca lightbox (yeni sekme AÇILMAZ) ----
240  _wireImageLightbox();
241  _wireVoiceNotes();
242  _wirePolls();
243  // ---- Mikrofon: sesli not kaydı ----
244  wireVoiceRecorder(newComposer, newSendBtn, chatInner);
245
246  // Sekmeye DÖNÜNCE okundu işaretle. Arka plandayken bilerek göndermiyoruz
247  // (kullanıcı mesajı görmeden karşı tarafta çift tik yanmasın), ama dönüşte de
248  // işaretlemezsek o sırada düşen mesajlar için su seviyesi ancak başka bir
249  // sohbete geçilince ilerler — karşı taraf "okumadı" görmeye devam eder.
250  document.addEventListener('visibilitychange', () => {
251    if (document.visibilityState !== 'visible') return;
252    if (state.activeType === 'dm' || state.activeType === 'group') {
253      _okunduIsaretle(state.conversationId);
254    }
255    // Sekmeye/uykudan dönüş canlı yayının en sık koptuğu an. `readyState`
256    // güvenilir DEĞİL: uyuyan makinede soket yarı-açık kalır, tarayıcı
257    // `onclose` üretmez ama tek çerçeve gelmez. Bu yüzden hem soketi
258    // tazeliyoruz hem de kaçanları REST ile topluyoruz.
259    canliTelafiIste(state.conversationId);
260  });
261  // Ağ geri geldi: kopukken düşen mesajlar için aynı telafi.
262  window.addEventListener('online', () => canliTelafiIste(state.conversationId));
263
264  // GÜVENLİK AĞI. Ölü soket tespiti (ping/pong) asıl çözüm, ama tespit
265  // edilemeyen bir arıza kalırsa kullanıcı açık duran sohbette hiçbir şey
266  // görmüyor ve ancak ekranlar arasında gezinip geri gelince eksikleri
267  // farkediyordu — bildirilen belirti tam olarak buydu ve "canlı" bir
268  // sohbette kabul edilemez.
269  //
270  // YALNIZ SEKME GÖRÜNÜRKEN ve bir sohbet AÇIKKEN: arka plan sekmeleri
271  // sunucuya yük bindirmesin. `_kacanMesajlariTopla` zaten 2 saniyelik
272  // kısmaya sahip ve yalnız EKSİK olanı çekiyor, yani sağlıklı yolda bu tur
273  // neredeyse bedava.
274  // Aralık SUNUCUDAN (`canli.telafi_sn`); tik sabit, karar eşiğe göre. Bu tur
275  // kullanıcının gördüğü gecikmenin ÜST SINIRI: ping/pong'un kaçırdığı her
276  // arızada mesaj en geç bu aralıkta ekrana geliyor.
277  let _sonTelafi = 0;
278  setInterval(() => {
279    if (document.visibilityState !== 'visible') return;
280    if (!state.conversationId) return;
281    const simdi = Date.now();
282    if (simdi - _sonTelafi < canliAyar('telafi_sn', 12)) return;
283    _sonTelafi = simdi;
284    canliTelafiIste(state.conversationId);
285  }, _AG_TIK);
286
287  // Expose chat-flow controls for the conversation-list module
288  window.morfozChat = {
289    state,
290    setActiveConversation,
291    renderUserBubble,
292    renderBotBubble: (msg) => appendBotBubble(chatInner, msg),
293    chatInner,
294    composer: newComposer,
295    sendBtn: newSendBtn,
296  };
297}
298
299/**
300 * Sohbet başlığındaki profile basıldığında AÇIK SOHBETİN kendi profilini aç.
301 *
302 * DM → karşı kişinin/sohbetin bilgi penceresi, grup → grup bilgisi (üyeler +
303 * davetli asistanlar). Asistan sohbetinde `false` döner ki çağıran (panel-app)
304 * asistan bilgi penceresini açsın. Bu ayrım burada yaşıyor çünkü açık sohbetin
305 * türünü yalnız chat-flow biliyor.
306 */
307export function openActiveChatProfile() {
308  const tip = state.activeType;
309  if (tip !== 'dm' && tip !== 'group') return false;
310  const conv = state.activeConvInfo || { id: state.conversationId, type: tip };
311  _openGroupInfoModal(conv);
312  return true;
313}
314
315/**
316 * Switch the chat view to a specific conversation.
317 * Loads ONLY THE LAST PAGE (PAGE_SIZE messages). Older messages stream in as
318 * the user scrolls up — see hookOlderScroll().
319 */
320/**
321 * Şifreli satırları YERİNDE çözer — ÇİZİM YOLLARININ TEK KAPISI.
322 *
323 * Panelde mesaj çizen DÖRT yol var (ilk sayfa, canlı yayın, yukarı kaydırma,
324 * kaçan mesaj telafisi). Çözmeyi her birine ayrı ayrı yazmak, birini unutmakla
325 * eşdeğerdi: unutulan yol ekrana HAM base64 zarf basar ve kullanıcı sohbetin
326 * bozulduğunu sanar. Bu yüzden tek yardımcı ve dört çağrı yeri.
327 *
328 * MODÜL DÜZEYİNDE OLMAK ZORUNDA. Girintisi sıfır olduğu için modül
329 * düzeyinde duruyor GİBİ görünüyordu ama aslında setActiveConversation'ın
330 * gövdesinin içindeydi (JS girintiye değil süslü paranteze bakar). Dört
331 * çağrı yerinden yalnız biri aynı kapsamdaydı; diğer üçü çalışma anında
332 * "satirlariCoz is not defined" atıyordu, yani şifreli sohbette CANLI
333 * gelen mesaj ve yukarı kaydırmayla yüklenen eski sayfa hiç çözülmüyordu.
334 *
335 * HATA YOLUNDA DA HAM METİN BASMIYORUZ: çözme düşerse satır "çözülemedi"
336 * olarak işaretleniyor — yedek yolun zarfı göstermesi, korumanın en görünür
337 * yerde delinmesi olurdu.
338 */
339async function satirlariCoz(satirlar) {
340  if (!Array.isArray(satirlar) || !satirlar.some(m => m && m.encrypted)) return satirlar;
341  try {
342    const mod = await import('./uctan-uca.js');
343    for (const m of satirlar) { if (m && m.encrypted) await mod.mesajiCoz(m); }
344  } catch {
345    for (const m of satirlar) {
346      if (m && m.encrypted) { m.text = t('e2e.undecryptable'); m.cozulemedi = true; }
347    }
348  }
349  return satirlar;
350}
351
352export async function setActiveConversation(convInfo) {
353  // convInfo: { id, assistant_id, assistant_name, assistant_avatar_tone, type?, peer?, title? }
354  const chatInner = document.querySelector('#chatView .chat-inner');
355  if (!chatInner) return;
356  state.conversationId = convInfo.id;
357  state.activeType = convInfo.type || 'assistant';   // 'assistant' | 'dm' | 'group'
358  state.activeConvInfo = convInfo;
359  // Masaüstü kabuğuna (macOS uygulaması) haber ver: AÇIK olan sohbette bildirim
360  // balonu gösterilmiyor (WhatsApp davranışı). Kabuk bunu yalnız adres
361  // çubuğundaki hash'ten çıkarsa yanlış çalışıyordu — panelde listeden sohbet
362  // değiştirmek hash'e dokunmuyor, dolayısıyla susturma ESKİ sohbete
363  // uygulanıyor, ekranda açık olanın mesajları balon olarak patlıyordu.
364  // Tarayıcıda `window.webkit` yok → tümüyle zararsız.
365  try {
366    window.webkit?.messageHandlers?.morfoz?.postMessage({
367      type: 'conv-open', id: convInfo.id,
368    });
369  } catch { /* köprü yok (tarayıcı) */ }
370  // DM/grup sohbetinde ASİSTAN YOK. Eskiden `|| state.activeAssistantId`
371  // yüzünden bir önceki asistan sohbetinin id'si yapışık kalıyordu: kişi/grup
372  // sohbetinde başlıktaki profile basınca alakasız bir asistanın bilgi
373  // penceresi açılıyordu (ya da hiç asistan açılmamışsa panel-app'in `|| 1`
374  // yedeği #1'i açıyordu — "hep aynı asistan geliyor" şikayeti).
375  const _isSocialConv = (convInfo.type === 'dm' || convInfo.type === 'group');
376  state.activeAssistantId = _isSocialConv
377    ? null
378    : (convInfo.assistant_id || state.activeAssistantId);
379  window.morfozActiveAssistantId = state.activeAssistantId;
380  // Owner of this assistant → training controls; everyone else → customer
381  // feedback bar (copy/like/dislike). Owner id arrives via convInfo. Default
382  // false (safe: a non-owner never sees training UI).
383  const _myId = _jwtUserId();
384  state.activeIsOwner = convInfo.assistant_owner_id != null && _myId != null
385    && String(convInfo.assistant_owner_id) === String(_myId);
386  // Reset pagination state for the new conversation
387  state.oldestLoadedId = null;
388  state.hasMoreOlder = true;
389  state.loadingOlder = false;
390  // Yarım kalan alıntı, açık menü, "yazıyor…" ve ESKİ sohbetin sabitlenmiş
391  // mesajı taşınmasın; yeni sohbetin sabiti yüklensin.
392  try { resetForConversation(convInfo.id); } catch {}
393
394  // ---- DM / grup sohbeti: başlık = kişi adı ya da grup adı ----
395  const isSocial = state.activeType === 'dm' || state.activeType === 'group';
396  if (isSocial) {
397    const header = document.querySelector('#chatView .header-info h1');
398    const headerAvatar = document.querySelector('#chatView .header-left .avatar');
399    const label = state.activeType === 'dm'
400      ? (convInfo.peer?.name || t('chat.person'))
401      : (convInfo.title || t('chat.group'));
402    if (header) {
403      header.textContent = label;
404      if (state.activeType === 'group') {
405        const badge = document.createElement('span');
406        badge.textContent = t('chat.badge_group');
407        badge.style.cssText = 'margin-left:8px;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;border-radius:5px;padding:1px 5px;vertical-align:middle;color:var(--text-primary, #4C3A78);background:rgba(107,35,135,.1);border:1px solid rgba(107,35,135,.22);';
408        header.appendChild(badge);
409      }
410    }
411    if (headerAvatar) {
412      headerAvatar.textContent = (label || '?').slice(0, 1).toUpperCase();
413      const tone = state.activeType === 'dm'
414        ? (convInfo.peer?.avatar_tone || 3) : ((convInfo.id % 6) + 1);
415      headerAvatar.className = 'avatar tone-' + tone;
416    }
417    // Asistan-özel yüzeyleri gizle; grup bilgi/yönetim panelini bağla.
418    _hideAssistantOnlyChrome();
419    _wireGroupInfoButton(convInfo);
420    // Başlık durum satırı + ✓✓ tikleri. try/catch: presence kozmetiktir,
421    // orada çıkan bir hata sohbetin AÇILMASINI engellememeli.
422    try { presence.setConversation(convInfo); } catch (e) { console.warn('[chat] presence', e); }
423  }
424
425  // Arama düğmeleri KOŞULSUZ bağlanıyor (isSocial dalının DIŞINDA): dalın
426  // içinde çağrılsaydı, DM'den asistan sohbetine geçince düğmeler başlıkta
427  // asılı kalırdı — asistanı insan gibi aramaya çalıştıran bir düÄ
427Ÿme.
428  // Fonksiyon zaten tipe bakıp DM dışında kendini gizliyor.
429  _wireCallButtons(convInfo);
430  // TANIMADIK KİŞİ ŞERİDİ — aynı gerekçeyle koşulsuz: önceki DM'nin şeridi
431  // her geçişte kalkmalı, yoksa asistan sohbetinin üstünde asılı kalırdı.
432  // BEKLENMİYOR: şerit sohbetin açılmasını geciktirmemeli; hata kütüphanede
433  // yutuluyor (uç yoksa şerit yok, sohbet bugünkü gibi).
434  _guvenlikSeridiniYukle(convInfo);
435
436  // Update header (assistant name + avatar)
437  const header = document.querySelector('#chatView .header-info h1');
438  const headerAvatar = document.querySelector('#chatView .header-left .avatar');
439  if (header && convInfo.assistant_name) {
440    header.textContent = '';
441    header.appendChild(document.createTextNode(convInfo.assistant_name));
442    // Sahiplik rozeti: claim onaylandıysa "✓ resmi", aksi halde topluluk demosu
443    // ise "resmi değil". Sohbet başlığında da görünür (sadece keşifte değil).
444    const _claim = convInfo.assistant_claim_status;
445    // Uyarı kuralı ortak modülde: klonlarda ölçü kişinin yaşıyor olması,
446    // kurumsalda seed_unofficial (bkz. api-client.resmiDegilUyarisiGerekli).
447    const _uyar = resmiDegilUyarisiGerekli({
448      claim_status: _claim,
449      klon_kind: convInfo.assistant_klon_kind,
450      klon_living: convInfo.assistant_klon_living,
451      seed_unofficial: convInfo.assistant_seed_unofficial,
452    });
453    if (_claim === 'approved' || _uyar) {
454      const badge = document.createElement('span');
455      const base = 'margin-left:8px;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;border-radius:5px;padding:1px 5px;vertical-align:middle;';
456      if (_claim === 'approved') {
457        badge.textContent = t('chat.badge_official');
458        badge.title = t('chat.badge_official_title');
459        badge.style.cssText = base + 'color:var(--success, #1B7A5E);background:rgba(42,157,124,.14);border:1px solid rgba(42,157,124,.35);';
460      } else {
461        badge.textContent = t('chat.badge_unofficial');
462        badge.title = t('chat.badge_unofficial_title');
463        badge.style.cssText = base + 'color:#8E6A00;background:#FFF3D6;border:1px solid #F0D89A;';
464      }
465      header.appendChild(badge);
466    }
467  }
468  if (headerAvatar && convInfo.assistant_name) {
469    headerAvatar.className = 'avatar tone-' + (convInfo.assistant_avatar_tone || 2);
470    if (convInfo.assistant_avatar_url) {
471      // textContent DEĞİL innerHTML: baş harf yazısı kalırsa görselin altında
472      // durup rozetin kenarından taşıyordu.
473      headerAvatar.innerHTML = '';
474      const im = document.createElement('img');
475      im.src = convInfo.assistant_avatar_url;
476      im.alt = '';
477      im.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block;';
478      headerAvatar.appendChild(im);
479    } else {
480      headerAvatar.textContent = convInfo.assistant_name.slice(0, 1).toUpperCase();
481    }
482  }
483
484  // Müşteri-yüzü modül sayfaları artık sol "Modüller" (işletme yönetimi)
485  // görünümüne ATLAMAZ; chat üst-sağ "Hizmetler" butonundan tam-ekran modalda
486  // açılır. Buton yalnız asistanın ekli modülü varsa görünür.
487  if (!isSocial) {
488    // Önceki DM/grup görünümünden kalan grup bilgi butonunu gizle.
489    const gib = document.getElementById('chatGroupInfoBtn');
490    if (gib) gib.style.display = 'none';
491    _wireChatServicesButton(state.activeAssistantId, convInfo.assistant_name);
492    _wireChatAvatarPicker(state.activeAssistantId, convInfo.assistant_name);
493    _renderQuickActionsStrip(state.activeAssistantId);
494    _wireChatCallButtons(state.activeAssistantId, convInfo.assistant_name, convInfo.id);
495    // Asistan sohbetinde presence tik/çevrimiçilik KURMAZ; yalnız başlığı
496    // "Yapay zeka asistanı · her zaman açık" diye boyar ve ÖNCEKİ kişi/grup
497    // sohbetinin 45 sn'lik yoklamasını durdurur — bu çağrı olmadan zamanlayıcı
498    // kapanmış sohbeti yoklamaya devam ederdi.
499    try { presence.setConversation(convInfo); } catch (e) { console.warn('[chat] presence', e); }
500  }
501
502  // Clear the chat-inner and show loading
503  chatInner.innerHTML = `<div style="text-align:center;padding:30px;color:#A89DB5;font-size:12px;">${escapeHtml(t('chat.loading_messages'))}</div>`;
504
505  // GEÇMİŞ SESLİ OKUNMAZ. Sesli yanıt gözlemcisi (voice.js) akışa DÜŞEN her
506  // asistan balonunu okur; geçmiş yüklemesi de akışa balon düşürdüğü için
507  // sohbeti açmak eski bir mesajın okunmasını başlatıyordu. Sesli yanıtın
508  // amacı GELEN cevabı duyurmaktır, arşivi değil.
509  try { window.morfozTtsSustur && window.morfozTtsSustur(); } catch {}
510
511  // `getMessagesCached` iki kez çizer: önce önbellekten (anında), sonra taze veri
512  // gelince GERİ ÇAĞRIYLA yeniden. O ikinci çizim yıkıcı (`innerHTML=''`) ve
513  // yarım saniye sonra düşüyor — kullanıcı o arada yukarı kaydırıp eski 
513sayfa
514  // yüklediyse hepsini siliyor ve dibe fırlatıyordu. Artık geç çizim yalnız
515  // GÜVENLİYSE yapılıyor.
516  let ilkSayfaEnEskiId = null;
517  const renderFirstPage = (messages, { ilkAcilis = true, taze = false } = {}) => {
518    // ŞİFRELİ SATIRLAR ÖNCE ÇÖZÜLÜR. Çözme asenkron olduğu için çizimi
519    // erteliyoruz; çözmeden çizersek kullanıcı bir an base64 zarf görür ve
520    // o an ekran görüntüsü alınırsa "şifreli" vaadi görsel olarak da çöker.
521    if (messages.some(m => m && m.encrypted)) {
522      satirlariCoz(messages).then(() => _cizFirstPage(messages, { ilkAcilis, taze }));
523      return;
524    }
525    _cizFirstPage(messages, { ilkAcilis, taze });
526  };
527
528  const _cizFirstPage = (messages, { ilkAcilis = true, taze = false } = {}) => {
529    // TAZE ÇİZİM KAZANIR. Şifreli satırlar asenkron çözüldüğü için önbellek
530    // sayfası, ağdan gelen sayfadan SONRA çizilebiliyor; o hâlde `innerHTML=''`
531    // taze mesajı ekrandan siler ve kullanıcı bir sonraki olaya kadar bayat
532    // sohbete bakar. Bayat sayfanın ekleyecek yeni bir şeyi de yoktur (id'leri
533    // daha küçüktür), bu yüzden tamamen çekiliyor.
534    if (!taze && tazeCizildiMi) return;
535    if (!ilkAcilis) {
536      if (state.conversationId !== convInfo.id) return;        // gerçekten alakasız
537      // Tam çizim yalnız "hâlâ ilk sayfadayız ve kullanıcı diptedir" iken
538      // güvenli; değilse veriyi atmak yerine eksikleri ekle.
539      const tamCizimGuvenli = state.oldestLoadedId === ilkSayfaEnEskiId
540        && (!state.dipteMi || state.dipteMi());
541      if (!tamCizimGuvenli) { _eksikleriEkle(chatInner, messages); return; }
542    }
543    chatInner.innerHTML = '';
544    // The "older messages loading…" sentinel — hidden until the user scrolls up
545    const sentinel = document.createElement('div');
546    sentinel.id = 'olderLoader';
547    sentinel.style.cssText = 'display:none;text-align:center;padding:10px;color:#A89DB5;font-size:11px;';
548    sentinel.textContent = t('chat.loading_older');
549    chatInner.appendChild(sentinel);
550    // Render the page in chronological order
551    messages.forEach(m => renderHistoricalMessage(chatInner, m));
552    // Track the oldest id loaded
553    if (messages.length > 0) {
554      state.oldestLoadedId = Math.min(...messages.map(m => m.id));
555      state.hasMoreOlder = messages.length >= PAGE_SIZE;
556    } else {
557      state.hasMoreOlder = false;
558    }
559    if (ilkAcilis) ilkSayfaEnEskiId = state.oldestLoadedId;
560    // Yukarıdaki kapı geç çizime ANCAK kullanıcı dipteyken izin veriyor —
561    // yani buraya ulaşmak zaten "dipteydi" demek. `ilkAcilis` göndermek, yeniden
562    // çizilen DOM'un büyümüş yüksekliği yüzünden kaydırmayı iptal ediyordu.
563    state.scrollToBottom && state.scrollToBottom(true);
564  };
565
566  // ---------------- MESAJLARIN YÜKLENMESİ ----------------
567  //
568  // AĞ İSTEĞİ EN BAŞTA ATILIYOR. Eskiden sıra şuydu: `await import(cache)` →
569  // IndexedDB'yi aç → önbelleği oku → ANCAK ONDAN SONRA sunucuya sor. Yani
570  // isteğin önünde bir modül ithali, bir veritabanı açılışı ve onlarca DOM
571  // yazımı vardı. Kullanıcının şikâyeti tam buydu: "listede güncel mesajı
572  // görüyorum, sohbete basınca ekrana çok sonra geliyor" — sunucu hızlıydı
573  // (ölçüm: /messages medyan 24 ms, p95 301 ms), geciken şey İSTEĞİN
574  // BAŞLAMASIYDI.
575  const tazeIstek = api.get(
576    `/api/conversations/${convInfo.id}/messages?limit=${PAGE_SIZE}`);
577  tazeIstek.catch(() => {});      // sahipsiz reddi sustur; aşağıda ele alınıyor
578
579  // BAYAT ÇİZİM TAZE ÇİZİMİ EZMESİN. Ağ, IndexedDB'den hızlı dönebiliyor
580  // (özellikle ilk açılışta: veritabanı açılışı + şifreli satırların
581  // çözülmesi asenkron). O durumda taze sayfa çiziliyor, hemen ardından
582  // önbellek sayfası `innerHTML=''` ile üstüne yazıyor ve yeni mesaj ekrandan
583  // SİLİNİYORDU — bir sonraki olaya kadar geri gelmiyordu.
584  let tazeCizildiMi = false;      // ÇİZİLEN sayfa ağdan mı geldi
585  let cizimYapildi = false;       // ekrana en az bir sayfa çizildi mi
586
587  // TAZE SAYFAYI HER HÂLÜKÂRDA ÇİZ. Eskiden çizim `onUpdate`e bağlıydı ve o da
588  // yalnız "önbellekten farklıysa" çağrılıyordu; önbellek zaten güncelken hiç
589  // çizim olmuyordu. Kapı burada değil `_cizFirstPage`te (kaydırma güvenliği).
590  const cizTaze = (rows) => {
591    if (state.conversationId !== convInfo.id) return;
592    const ilk = !cizimYapildi;
593    cizimYapildi = true;
594    tazeCizildiMi = true;
595    renderFirstPage(Array.isArray(rows) ? rows : [], { ilkAcilis: ilk, taze: true });
596  };
597
598  yenilikSeridi.baslat(convInfo.id);
599  try {
600    // Lazy-import the cache helper to avoid circular dependency
601    const { getMessagesCached } = await import('./morfoz-cache.js');
602    // `hazirIstek` — yukarıda başlattığımız istek. `zorla: true` de duruyor:
603    // sohbeti AÇMAK kullanıcının açık eylemi, 30 saniyelik arka plan kısıtına
604    // takılmamalı. Takıldığında olan şuydu: bildirim geliyordu, kullanıcı
605    // saniyeler içinde sohbeti açıyordu ve önbellek "daha yeni çektim" deyip
606    // ağa hiç çıkmıyordu — mesaj ekranda yoktu.
607    const cached = await getMessagesCached(convInfo.id, null, {
608      zorla: true,
609      hazirIstek: tazeIstek,
610      onDurum: (durum, ek) => {
611        if (state.conversationId !== convInfo.id) return;
612        if (durum === 'bitti') { yenilikSeridi.bitir(); cizTaze(ek); }
613        if (durum === 'hata') {
614          // Hiçbir şey çizilmediyse ekran boş kalmasın; çizildiyse bayat
615          // sayfanın üstünde "güncellenemedi" şeridi durur.
616          if (!cizimYapildi) {
617            chatInner.innerHTML = `<div style="text-align:center;padding:30px;color:var(--danger, #B83838);font-size:12px;">${t('chat.load_failed', { error: escapeHtml(ek?.message || '') })}</div>`;
618          }
619          yenilikSeridi.hata(convInfo);
620        }
621      },
622    });
623    if (cached.length && !tazeCizildiMi) {
624      cizimYapildi = true;
625      renderFirstPage(cached);
626    }
627  } catch (e) {
628    yenilikSeridi.bitir();
629    chatInner.innerHTML = `<div style="text-align:center;padding:30px;color:var(--danger, #B83838);font-size:12px;">${t('chat.load_failed', { error: escapeHtml(e.message) })}</div>`;
630  }
631
632  // Canlı bağlantı: operatör (rep) yanıtlarını + sistem notlarını anında al.
633  // Her konuşma değişiminde önceki soket kapanır, yenisi açılır.
634  state.seenLiveIds = new Set();
635  _beklemeNotunuKaldir();        // önceki sohbetin bekleme notu taşınmasın
636  _connectLiveSocket(convInfo.id);
637
638  // Uçtan uca şifreleme durumu. YALNIZ İKİLİ SOHBET: grupta açılamıyor
639  // (bkz. services/uctan_uca.py), o yüzden sormuyoruz bile.
640  // `state.e2e` sıfırlaması KAPININ ARKASINDA: hızlı sohbet değiştirmede bu
641  // satır önceki sohbetin geç dönen isteğinden SONRA çalışıp YENİ sohbetin
642  // durumunu siliyordu — şifreli sohbete düz metin gönderme denemesi (400).
643  if (state.conversationId !== convInfo.id) return;
644  state.e2e = null;
645  if (convInfo.type === 'dm') {
646    import('./uctan-uca.js').then(async (mod) => {
647      // Cihaz anahtarını HER AÇILIŞTA bildiriyoruz: sunucudaki kayıt "bu
648      // cihaz hâlâ ayakta" demek. Bildirmezsek karşı taraf bu tarayıcıya
649      // mesaj sarmalamaz ve kullanıcı KENDİ mesajlarını bile okuyamaz.
650      await mod.cihazAnahtariniHazirla();
651      if (state.conversationId !== convInfo.id) return;
652      state.e2e = await mod.durum(convInfo.id);
653      _e2eRozetiCiz();
654    }).catch(() => { /* şifreleme kurulamadıysa sohbet normal çalışır */ });
655  }
656
657  // Auto-focus the message composer so the user can start typing immediately
658  // after selecting an assistant — WhatsApp / Telegram convention.
659  setTimeout(() => {
660    const composer = document.querySelector('#chatView .composer-input');
661    if (composer) composer.focus();
662  }, 80);
663}
664
665
666// ---------- Chat üst-sağ "Hizmetler" butonu (müşteri-yüzü modül sayfaları) ----------
667//
668// Sol "Modüller" görünümü = İŞLETME yönetimi (sahip). Müşterinin göreceği modül
669// sayfaları (rezervasyon yap, QR menü, randevu al, sipariş ver…) buradan, chat'in
670// üst-sağ köşesindeki "Hizmetler" butonuyla TAM-EKRAN MODALDA açılır — sol menüye
671// atlamaz. Buton yalnızca asistanın ekli modülü varsa görünür.
672
673const _moduleStripCache = new Map();   // assistantId -> { modules, role, name, fetchedAt }
674
675async function _wireChatServicesButton(assistantId, assistantName) {
676  const btn = document.getElementById('chatServicesBtn');
677  if (!btn) return;
678  btn.style.display = 'none';            // veri gelene kadar gizli
679  if (!api.token) return;
680  // 60 sn cache — konuşma değiştikçe uca yüklenmesin.
681  const cached = _moduleStripCache.get(assistantId);
682  let entry;
683  if (cached && Date.now() - cached.fetchedAt < 60000) {
684    entry = cached;
685  } else {
686    try {
687      const rows = await api.get('/api/assistants/accessible');
688      const row = rows.find(r => r.id === assistantId);
689      entry = row
690        ? { modules: row.modules || [], role: row.my_role, name: row.name, fetchedAt: Date.now() }
691        : { modules: [], role: 'customer', name: assistantName, fetchedAt: Date.now() };
692      _moduleStripCache.set(assistantId, entry);
693    } catch (e) {
694      return;   // 401/ağ — sessiz; sonraki konuşma geçişinde tekrar denenir
695    }
696  }
697  if (!entry.modules.length) return;     // ekli modül yok → buton gizli kalır
698  const labelEl = btn.querySelector('.cs-btn-label');
699  if (labelEl) labelEl.textContent = t('chat.services_count', { count: entry.modules.length });
700  btn.style.display = 'inline-flex';
701  // Tek handler tut (konuşma değişince yeniden bağlanır).
702  btn.onclick = () => {
703    if (window.morfozModulesNav && window.morfozModulesNav.openCustomerModulesModal) {
704      window.morfozModulesNav.openCustomerModulesModal(assistantId, entry.name, entry.modules);
705    }
706  };
707}
708
709
710// ---------- Chat avatar seçici (müşteri, asistana bağlı avatarlardan seçer) ----------
711//
712// Asistana bağlı avatar varsa chat header'ına bir "Avatar" butonu eklenir.
713// Müşteri bir avatar seçince: animasyonlu avatar sağ-alt PiP'te (talkingAvatar)
714// canlanır, sesli yanıt (TTS) otomatik açılır ve konuşurken avatar ağzı oynar.
715const _avatarCache = new Map();   // assistantId -> { avatars, fetchedAt }
716let _activeAvatarCtl = null;
717
718// NOT: Sentetik animasyonlu avatar (TTS + CSS dudak senkronu) D-ID'den
719// BAĞIMSIZDIR ve kredi olmasa da çalışır. Bu yüzden avatar butonu D-ID
720// kredisine bağlanmaz — kredi bitince yalnızca D-ID videosu (voice.js)
721// devre dışı kalır, sentetik yüz akışta kalır.
722
723// Bağlı avatarı olmayan asistan için ASİSTANA ÖZGÜ insansı fallback yüz —
724// backend'in görüntülü arama fallback'iyle (calls_routes.HUMAN_PRESET_KEYS,
725// id % 8) BİREBİR aynı seçim: sohbet PiP'i ile görüntülü arama aynı yüzü
726// gösterir, her asistanın yüzü FARKLI olur.
727const HUMANOID_FALLBACK_KEYS = ['deniz', 'elif', 'mert', 'zeynep', 'ozan', 'leyla', 'kerem', 'sara'];
728function _fallbackAvatarFor(assistantId) {
729  const key = HUMANOID_FALLBACK_KEYS[(Number(assistantId) || 0) % HUMANOID_FALLBACK_KEYS.length];
730  const meta = AVATAR_PRESETS.find(p => p.key === key);
731  return meta ? { ...meta, type: 'preset', ref: 'preset:' + key } : null;
732}
733
734async function _wireChatAvatarPicker(assistantId, assistantName) {
735  const headerActions = document.querySelector('#chatView .header-actions');
736  if (!headerActions) return;
737  let btn = document.getElementById('chatAvatarBtn');
738  if (!btn) {
739    btn = document.createElement('button');
740    btn.id = 'chatAvatarBtn';
741    btn.className = 'icon-btn';
742    btn.title = t('chat.avatar_pick');
743    btn.setAttribute('aria-label', t('chat.avatar'));
744    // Gülen-yüzlü avatar karosu — görüntülü arama (kamera) ikonundan net ayrışır.
745    btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="9" cy="10" r="1.1" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.1" fill="currentColor" stroke="none"/><path d="M8.5 14.5s1.3 1.8 3.5 1.8 3.5-1.8 3.5-1.8"/></svg>';
746    headerActions.prepend(btn);
747  }
748  btn.style.display = 'none';
749  if (!api.token) return;
750  let avatars;
751  const cached = _avatarCache.get(assistantId);
752  if (cached && Date.now() - cached.fetchedAt < 60000) {
753    avatars = cached.avatars;
754  } else {
755    try {
756      avatars = await api.get(`/api/avatars/assistants/${assistantId}`);
757      _avatarCache.set(assistantId, { avatars, fetchedAt: Date.now() });
758    } catch {
759      // Havuz okunamadı — ÖNCEKİ asistanın yüzü ekranda kalmasın; ağ gerektirmeyen
760      // asistana-özgü fallback göster.
761      const fb = _fallbackAvatarFor(assistantId);
762      if (fb && _savedAvatarRef(assistantId) !== 'off') _activateAvatar(assistantId, assistantName, fb, { auto: true });
763      return;
764    }
765  }
766  // Müşteri bu asistan için avatarı bilinçli kapattıysa: PiP'i kapat (önceki
767  // asistanın yüzü de EKRANDA KALMASIN) ve otomatik açma.
768  const saved = _savedAvatarRef(assistantId);
769  if (saved === 'off') { _deactivateAvatar(); return; }
770
771  if (!avatars || !avatars.length) {
772    // Bağlı avatar yok → asistana ÖZGÜ insansı fallback (görüntülü aramadaki
773    // yüzün aynısı). Önceden burada erken çıkılıyordu; PiP'te ya varsayılan
774    // sabit yüz ya da ÖNCEKİ asistanın avatarı kalıyordu — "hep aynı kişi"
775    // şikayetinin sebebi buydu.
776    const fb = _fallbackAvatarFor(assistantId);
777    if (fb) _activateAvatar(assistantId, assistantName, fb, { auto: true });
778    else _deactivateAvatar();
779    return;                                    // seçici buton gizli kalır (havuz boş)
780  }
781  btn.style.display = 'inline-flex';
782  btn.onclick = () => _toggleAvatarPopover(btn, assistantId, assistantName, avatars);
783
784  // OTOMATİK GÖSTERİM: sahip asistana bir yüz atadıysa, müşteri chat'i açar
785  // açmaz avatar sağ-alt PiP'te canlanır (idle: nefes + göz kırpma), bot cevap
786  // verince sesli okuyup ağzı/mimikleri oynar. Müşteri "Avatarı kapat" derse
787  // tercihi ('off') hatırlanır ve bir daha otomatik açılmaz.
788  const auto = avatars.find(a => a.ref === saved) || avatars[0];
789  if (auto) _activateAvatar(assistantId, assistantName, auto, { auto: true });
790}
791
792function _toggleAvatarPopover(btn, assistantId, assistantName, avatars) {
793  let pop = document.getElementById('chatAvatarPop');
794  if (pop) { pop.remove(); return; }
795  pop = document.createElement('div');
796  pop.id = 'chatAvatarPop';
797  pop.style.cssText = 'position:absolute;z-index:1300;background:var(--surface-1,#fff);border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 30px rgba(20,10,40,.2);padding:12px;display:grid;grid-template-columns:repeat(3,72px);gap:8px;';
798  const saved = _savedAvatarRef(assistantId);
799  avatars.forEach(av => {
800    const on = av.ref === saved;
801    const cell = document.createElement('button');
802    cell.style.cssText = `border:2px solid ${on ? 'var(--brand,#6B2387)' : 'transparent'};border-radius:10px;background:var(--surface-2,rgba(0,0,0,.03));padding:5px;cursor:pointer;`;
803    cell.innerHTML = `<div class="cap-prev" style="width:56px;height:70px;margin:0 auto;"></div><div style="font-size:10.5px;font-weight:600;margin-top:2px;">${escapeHtml(av.name)}</div>`;
804    renderAvatar(cell.querySelector('.cap-prev'), av);
805    cell.addEventListener('click', () => { _activateAvatar(assistantId, assistantName, av); pop.remove(); });
806    pop.appendChild(cell);
807  });
808  // "Kapat / avatarı gizle" satırı
809  const off = document.createElement('button');
810  off.textContent = t('chat.avatar_off');
811  off.style.cssText = 'grid-column:1/-1;border:none;background:transparent;color:var(--text-tertiary);font-size:11.5px;cursor:pointer;padding:4px;';
812  off.addEventListener('click', () => { _deactivateAvatar(assistantId); pop.remove(); });
813  pop.appendChild(off);
814
815  document.body.appendChild(pop);
816  const r = btn.getBoundingClientRect();
817  pop.style.top = (r.bottom + 6) + 'px';
818  pop.style.left = Math.max(8, r.right - pop.offsetWidth) + 'px';
819  setTimeout(() => {
820    const onDoc = (e) => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('click', onDoc); } };
821    document.addEventListener('click', onDoc);
822  }, 0);
823}
824
825function _savedAvatarRef(assistantId) {
826  try { return JSON.parse(localStorage.getItem('morfoz_avatar_sel_v1') || '{}')[assistantId] || null; }
827  catch { return null; }
828}
829function _saveAvatarRef(assistantId, ref) {
830  try {
831    const m = JSON.parse(localStorage.getItem('morfoz_avatar_sel_v1') || '{}');
832    if (ref) m[assistantId] = ref; else delete m[assistantId];
833    localStorage.setItem('morfoz_avatar_sel_v1', JSON.stringify(m));
834  } catch {}
835}
836
837function _activateAvatar(assistantId, assistantName, av, opts = {}) {
838  const pip = document.getElementById('talkingAvatar');
839  if (!pip) return;
840  const body = pip.querySelector('.ta-body');
841  if (!body) return;
842  // Mevcut basit yüzü gizle, animasyonlu avatarı bas.
843  const face = body.querySelector('.ta-face');
844  if (face) face.style.display = 'none';
845  let host = body.querySelector('.ta-custom-host');
846  if (!host) {
847    host = document.createElement('div');
848    host.className = 'ta-custom-host';
849    host.style.cssText = 'position:absolute;inset:0;display:flex;align-items:flex-end;justify-content:center;';
850    body.style.position = 'relative';
851    body.appendChild(host);
852  }
853  host.style.display = '';
854  _activeAvatarCtl = renderAvatar(host, av);
855  // PiP'i göster + konuşma kancası + sesli yanıtı aç.
856  pip.classList.add('open');
857  pip.setAttribute('aria-hidden', 'false');
858  const meta = pip.querySelector('#taMeta');
859  if (meta) meta.textContent = `${av.name} · ${assistantName || ''}`;
860  window.morfozAvatarTalk = (on) => { try { _activeAvatarCtl && _activeAvatarCtl.talk(on); } catch {} };
861  // Sesli yanıt YALNIZ kullanıcı avatarı kendi seçtiğinde ve daha önce sesi
862  // kapatmamışsa açılır. `opts.auto` sahibin atadığı yüzün kendiliğinden
863  // gösterilmesidir — kullanıcı hiçbir şey istememiştir, sesi açmak sohbeti
864  // açar açmaz konuşmaya başlamak demektir (canlı şikâyet, bkz. voice.js).
865  if (!opts.auto && window.morfozSetTts && window.morfozTtsTercihi?.() !== 'off') {
866    window.morfozSetTts(true);
867  }
868  _saveAvatarRef(assistantId, av.ref);
869  // Otomatik gösterimde (sahip yüz atadığı için) sessiz — toast rahatsız etmesin.
870  if (!opts.auto) toast(t('chat.avatar_enabled', { name: av.name }), 'success');
871}
872
873function _deactivateAvatar(assistantId) {
874  const pip = document.getElementById('talkingAvatar');
875  if (pip) {
876    const host = pip.querySelector('.ta-custom-host');
877    if (host) host.style.display = 'none';
878    const face = pip.querySelector('.ta-face');
879    if (face) face.style.display = '';
880    pip.classList.remove('open');
881    pip.setAttribute('aria-hidden', 'true');
882  }
883  window.morfozAvatarTalk = null;
884  _activeAvatarCtl = null;
885  // Müşteri kapattıysa bu asistanda bir daha OTOMATİK açma (tercihi hatırla).
886  if (assistantId != null) _saveAvatarRef(assistantId, 'off');
887}
888
889
890// ---------- Sesli / görüntülü arama butonları (asistan + klon sohbetleri) ----------
891const _callCfgCache = new Map();   // assistantId -> { cfg, fetchedAt }
892
893async function _wireChatCallButtons(assistantId, assistantName, conversationId) {
894  const headerActions = document.querySelector('#chatView .header-actions');
895  if (!headerActions) return;
896  let vbtn = document.getElementById('chatVideoCallBtn');
897  let abtn = document.getElementById('chatVoiceCallBtn');
898  if (!abtn) {
899    abtn = document.createElement('button');
900    abtn.id = 'chatVoiceCallBtn';
901    abtn.className = 'icon-btn';
902    abtn.title = t('chat.voice_call');
903    abtn.setAttribute('aria-label', t('chat.voice_call'));
904    abtn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
905    headerActions.prepend(abtn);
906  }
907  if (!vbtn) {
908    vbtn = document.createElement('button');
909    vbtn.id = 'chatVideoCallBtn';
910    vbtn.className = 'icon-btn';
911    vbtn.title = t('chat.video_call');
912    vbtn.setAttribute('aria-label', t('chat.video_call'));
913    vbtn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m22 8-6 4 6 4V8Z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>';
914    headerActions.prepend(vbtn);
915  }
916  // GERÇEK ZAMANLI GÖRÜŞME — yalnız sunucu "açılabilir" derken görünür
917  // (şalter açık, kota var, asistanın görüşmesi açık). Görünüp hemen
918  // "kullanılamıyor" diye kapanan bir ekran açmasın.
919  let rbtn = document.getElementById('chatRealtimeCallBtn');
920  if (!rbtn) {
921    rbtn = document.createElement('button');
922    rbtn.id = 'chatRealtimeCallBtn';
923    rbtn.className = 'icon-btn';
924    rbtn.title = t('chat.realtime_call');
925    rbtn.setAttribute('aria-label', t('chat.realtime_call'));
926    rbtn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3v18"/><path d="M8 7v10"/><path d="M16 7v10"/><path d="M4 10v4"/><path d="M20 10v4"/></svg>';
927    headerActions.prepend(rbtn);
928  }
929  abtn.style.display = 'none';
930  vbtn.style.display = 'none';
931  rbtn.style.display = 'none';
932  // Düğme en son hangi asistan için kuruldu: geç dönen durum sorgusu, arada
933  // açılan başka bir sohbetin başlığında düğmeyi yanlışlıkla göstermesin.
934  rbtn.dataset.asistan = String(assistantId);
935  if (!api.token) return;
936  let cfg;
937  const cached = _callCfgCache.get(assistantId);
938  if (cached && Date.now() - cached.fetchedAt < 60000) {
939    cfg = cached.cfg;
940  } else {
941    try {
942      cfg = await api.get(`/api/calls/config?assistant_id=${assistantId}`);
943      _callCfgCache.set(assistantId, { cfg, fetchedAt: Date.now() });
944    } catch { return; }
945  }
946  if (!cfg.enabled) return;
947  abtn.style.display = 'inline-flex';
948  vbtn.style.display = 'inline-flex';
949  abtn.onclick = () => openCallUI({ assistantId, assistantName, conversationId: state.conversationId, mode: 'voice' });
950  vbtn.onclick = () => openCallUI({ assistantId, assistantName, conversationId: state.conversationId, mode: 'video' });
951  // Tıklama işleyicisi SENKRON çağırıyor: ses bağlamı ilk `await`ten önce
952  // kurulmalı (bkz. openGercekZamanli).
953  rbtn.onclick = () => openGercekZamanli({ assistantId, assistantName, conversationId: state.conversationId });
954  const rt = await gercekZamanliDurumu(assistantId);
955  if (rt && rt.acik && rbtn.dataset.asistan === String(assistantId)) {
956    rbtn.style.display = 'inline-flex';
957  }
958}
959
960// DM/grup sohbetinde asistan-özel arayüz parçalarını gizle.
961function _hideAssistantOnlyChrome() {
962  ['chatServicesBtn', 'chatAvatarBtn', 'chatVoiceCallBtn', 'chatVideoCallBtn', 'chatRealtimeCallBtn'].forEach(id => {
963    const el = document.getElementById(id);
964    if (el) el.style.display = 'none';
965  });
966  const strip = document.getElementById('chatQuickActions');
967  if (strip) strip.style.display = 'none';
968  // Önceki asistan sohbetinden kalan konuşan-avatar PiP'i kişi/grup sohbetinde kapanır.
969  _deactivateAvatar();
970}
971
972
973// ---------- Uçtan uca şifreleme rozeti ----------
974
975/**
976 * Başlıktaki kilit rozeti + aç/kapat.
977 *
978 * ROZET ŞART: şifreli olup olmadığını GÖREMEYEN kullanıcı için özellik yok
979 * hükmündedir — hangi sohbette korunduğunu bilmeden davranışını değiştiremez.
980 */
981function _e2eRozetiCiz() {
982  const header = document.querySelector('#chatView .header-info h1');
983  if (!header) return;
984  header.querySelector('.e2e-rozet')?.remove();
985  const d = state.e2e;
986  if (!d || !d.available) return;
987  const el = document.createElement('span');
988  el.className = 'e2e-rozet';
989  el.textContent = d.enabled ? `🔒 ${t('e2e.on')}` : `🔓 ${t('e2e.off')}`;
990  el.title = t('e2e.badge_title');
991  el.style.cssText = 'margin-left:8px;font-size:9.5px;font-weight:700;border-radius:5px;'
992    + 'padding:1px 5px;vertical-align:middle;cursor:pointer;'
993    + (d.enabled
994       ? 'color:var(--success, #1B5E3A);background:rgba(27,158,90,.12);border:1px solid rgba(27,158,90,.3);'
995       : 'color:var(--text-secondary, #6B5F7E);background:rgba(107,95,126,.10);border:1px solid rgba(107,95,126,.22);');
996  el.addEventListener('click', _e2eModali);
997  header.appendChild(el);
998}
999
1000/**
1001 * Şifreleme modalı — AÇARKEN NE KAYBEDİLDİĞİ YAZILI.
1002 *
1003 * Kullanıcı bu listeyi görmeden karar veremez: sohbette arama, bildirim
1004 * önizlemesi ve KLONUN CEVAP VERMESİ kapanıyor, yeni cihazda geçmiş
1005 * açılmıyor. Bunları yazmadan bir anahtar sunmak, sessizce özellik
1006 * kaybettirmek olurdu.
1007 */
1008async function _e2eModali() {
1009  const d = state.e2e;
1010  if (!d) return;
1011  const eski = document.getElementById('mzE2EOverlay');
1012  if (eski) eski.remove();
1013  const ov = document.createElement('div');
1014  ov.id = 'mzE2EOverlay';
1015  ov.className = 'mz-e2e-overlay';
1016  const numaralar = (d.safety_numbers && d.safety_numbers.users) || [];
1017  ov.innerHTML = `
1018    <div class="mz-e2e-card">
1019      <h3>${escapeHtml(t('e2e.title'))}</h3>
1020      <p class="mz-e2e-sub">${escapeHtml(d.enabled ? t('e2e.state_on') : t('e2e.state_off'))}</p>
1021      <div class="mz-e2e-uyari">
1022        <strong>${escapeHtml(t('e2e.tradeoffs_title'))}</strong>
1023        <ul>
1024          <li>${escapeHtml(t('e2e.lose_search'))}</li>
1025          <li>${escapeHtml(t('e2e.lose_preview'))}</li>
1026          <li>${escapeHtml(t('e2e.lose_clone'))}</li>
1027          <li>${escapeHtml(t('e2e.lose_history'))}</li>
1028        </ul>
1029      </div>
1030      <div class="mz-e2e-guvenlik">
1031        <div class="mz-e2e-baslik">${escapeHtml(t('e2e.safety_title'))}</div>
1032        <p class="mz-e2e-not">${escapeHtml(t('e2e.safety_hint'))}</p>
1033        ${numaralar.map(u => `
1034          <div class="mz-e2e-kisi">
1035            <div class="mz-e2e-kim">#${u.user_id}</div>
1036            ${(u.keys || []).map(k => `<code>${escapeHtml(k)}</code>`).join('')}
1037          </div>`).join('')}
1038      </div>
1039      <div class="mz-e2e-dugmeler">
1040        <button class="link" id="mzE2EKapat">${escapeHtml(t('common.close'))}</button>
1041        <button class="primary" id="mzE2EDegistir">${escapeHtml(
1042          d.enabled ? t('e2e.turn_off') : t('e2e.turn_on'))}</button>
1043      </div>
1044    </div>`;
1045  document.body.appendChild(ov);
1046  _e2eStil();
1047  const kapat = () => ov.remove();
1048  ov.querySelector('#mzE2EKapat').addEventListener('click', kapat);
1049  ov.addEventListener('click', (e) => { if (e.target === ov) kapat(); });
1050  ov.querySelector('#mzE2EDegistir').addEventListener('click', async () => {
1051    const mod = await import('./uctan-uca.js');
1052    try {
1053      state.e2e = d.enabled ? await mod.kapat(state.conversationId)
1054                            : await mod.ac(state.conversationId);
1055      _e2eRozetiCiz();
1056      kapat();
1057      toast(state.e2e.enabled ? t('e2e.turned_on') : t('e2e.turned_off'), 'success');
1058    } catch (e) {
1059      toast(e.message || t('e2e.failed'), 'error');
1060    }
1061  });
1062}
1063
1064function _e2eStil() {
1065  if (document.getElementById('mzE2EStil')) return;
1066  const st = document.createElement('style');
1067  st.id = 'mzE2EStil';
1068  st.textContent = `
1069    .mz-e2e-overlay{position:fixed;inset:0;z-index:8800;display:flex;align-items:center;
1070      justify-content:center;background:rgba(12,6,26,.55);}
1071    .mz-e2e-card{width:min(94vw,440px);max-height:86vh;overflow:auto;padding:22px;
1072      border-radius:16px;background:var(--surface, #fff);box-shadow:0 20px 50px rgba(20,10,40,.3);}
1073    .mz-e2e-card h3{margin:0 0 4px;font-size:17px;color:var(--text-primary, #1B1030);}
1074    .mz-e2e-sub{margin:0 0 14px;font-size:12.5px;color:var(--text-secondary, #6B5F7E);}
1075    .mz-e2e-uyari{padding:11px 13px;border-radius:10px;background:rgba(179,38,30,.06);
1076      border:1px solid rgba(179,38,30,.18);font-size:12px;color:var(--danger, #7A2018);}
1077    .mz-e2e-uyari ul{margin:6px 0 0;padding-left:18px;line-height:1.6;}
1078    .mz-e2e-guvenlik{margin-top:16px;}
1079    .mz-e2e-baslik{font-size:12.5px;font-weight:700;color:var(--text-primary, #1B1030);}
1080    .mz-e2e-not{margin:4px 0 8px;font-size:11.5px;color:var(--text-secondary, #6B5F7E);line-height:1.55;}
1081    .mz-e2e-kisi{margin-bottom:8px;}
1082    .mz-e2e-kim{font-size:11px;color:#8A7CA0;margin-bottom:2px;}
1083    .mz-e2e-kisi code{display:block;font-size:12px;letter-spacing:.06em;color:var(--text-primary, #3A2B55);
1084      background:var(--surface-2, #F4F0FA);border-radius:6px;padding:6px 8px;margin-bottom:4px;
1085      word-break:break-all;}
1086    .mz-e2e-dugmeler{margin-top:18px;display:flex;gap:9px;justify-content:flex-end;}
1087    .mz-e2e-dugmeler button{padding:9px 15px;border:0;border-radius:9px;font-size:13px;
1088      font-weight:600;cursor:pointer;}
1089    .mz-e2e-dugmeler .link{background:transparent;color:var(--text-secondary, #6B5F7E);}
1090    .mz-e2e-dugmeler .primary{background:#6D3BEB;color:#fff;}
1091    /* GECE MODU: panel temayi data-theme ile uyguluyor (dark-mode.js hem
1092       body.dataset.theme hem documentElement.dataset.theme yaziyor),
1093       prefers-color-scheme ILE DEGIL. Medya sorgusu kullanmak iki yonlu
1094       hataydi:
1095         - Kullanici gece moduna elle gectiginde bu kurallar HIC calismiyor,
1096           ama body[data-theme="dark"] kurali metni acik renge ceviriyordu
1097           -> BEYAZ KART USTUNDE BEYAZ YAZI (bildirilen hata).
1098         - Tersi de olurdu: isletim sistemi karanlikken kullanici acik temayi
1099           SECMISSE, medya sorgusu yine de karti karartiyordu.
1100       Tek kaynak: [data-theme="dark"].
1101       NOT: bu blok bir SABLON DIZESININ icinde — buraya ters tirnak
1102       yazilirsa dize orada kapanir ve panel hic acilmaz. */
1103    [data-theme="dark"] .mz-e2e-card{background:#1A1030;}
1104    [data-theme="dark"] .mz-e2e-card h3,[data-theme="dark"] .mz-e2e-baslik{color:#F0EAF7;}
1105    [data-theme="dark"] .mz-e2e-kisi code{background:#241740;color:#E7DEF6;}`;
1106  document.head.appendChild(st);
1107}
1108
1109// ---------- Sesli / görüntülü arama düğmeleri ----------
1110
1111/**
1112 * DM'de İKİLİ, grupta ÇOK TARAFLI (örgü) çağrı — iki ayrı modül, tek düğme
1113 * çifti. Asistan sohbetinde düğmeler gizleniyor: orada asistanın kendi
1114 * çağrısı var (`_wireChatCallButtons`) ve ikisi karışmamalı.
1115 */
1116function _wireCallButtons(convInfo) {
1117  const headerActions = document.querySelector('#chatView .header-actions');
1118  if (!headerActions) return;
1119  const ikili = (convInfo.type === 'dm') && convInfo.peer && convInfo.peer.id;
1120  const grup = convInfo.type === 'group';
1121
1122  for (const [id, gorusme, ikon] of [
1123    ['chatCallAudioBtn', false,
1124     '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8.1 9.9a16 16 0 0 0 6 6l1.4-1.2a2 2 0 0 1 2.1-.5c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>'],
1125    ['chatCallVideoBtn', true,
1126     '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>'],
1127  ]) {
1128    let btn = document.getElementById(id);
1129    if (!btn) {
1130      btn = document.createElement('button');
1131      btn.id = id;
1132      btn.className = 'icon-btn';
1133      btn.innerHTML = ikon;
1134      headerActions.prepend(btn);
1135    }
1136    const etiket = gorusme ? t('calls.video') : t('calls.audio');
1137    btn.title = etiket;
1138    btn.setAttribute('aria-label', etiket);
1139    btn.style.display = (ikili || grup) ? 'inline-flex' : 'none';
1140    btn.onclick = async () => {
1141      // Modül İSTEK ANINDA yükleniyor: WebRTC kodu ve arayüzü, hiç arama
1142      // yapmayacak kullanıcının açılışını yavaşlatmasın.
1143      if (grup) {
1144        const mod = await import('./grup-arama.js');
1145        mod.baslat(convInfo.id, { video: gorusme, baslik: convInfo.title || '' });
1146        return;
1147      }
1148      const mod = await import('./insan-arama.js');
1149      mod.ara(convInfo.id, convInfo.peer.id, { video: gorusme });
1150    };
1151  }
1152  if (grup) _grupSeridiniTazele(convInfo);
1153  else _grupSeridiniKaldir();
1154}
1155
1156// ---------- "Görüşme sürüyor · Katıl" şeridi (yalnız grup) ----------
1157
1158/**
1159 * Zil ÇALMAYABİLİR — kalabalık grupta sunucu kimseyi çaldırmıyor (500 kişilik
1160 * bir toplulukta herkesin telefonunu çaldırmak taciz olurdu). O durumda süren
1161 * görüşmeden haberdar olmanın TEK yolu bu şerit; olmasaydı özellik kalabalık
1162 * gruplarda görünmez kalırdı.
1163 */
1164function _grupSeridiniKaldir() {
1165  document.getElementById('chatGCallStrip')?.remove();
1166}
1167
1168function _grupSeridiniCiz(convInfo, cagri) {
1169  const composerWrap = document.querySelector('#chatView .composer-wrap')
1170                    || document.querySelector('.composer-wrap');
1171  if (!composerWrap) return;
1172  let el = document.getElementById('chatGCallStrip');
1173  if (!el) {
1174    el = document.createElement('div');
1175    el.id = 'chatGCallStrip';
1176    el.style.cssText = 'display:flex;align-items:center;gap:10px;margin:0 22px 8px;'
1177      + 'padding:8px 12px;border-radius:10px;background:rgba(27,158,90,.10);'
1178      + 'border:1px solid rgba(27,158,90,.28);font-size:12.5px;color:var(--success, #1B5E3A);';
1179    composerWrap.parentNode.insertBefore(el, composerWrap);
1180  }
1181  const tur = cagri.kind === 'video' ? t('calls.video') : t('calls.audio');
1182  el.innerHTML = `
1183    <span style="font-weight:700;">${escapeHtml(t('gcall.ongoing', { kind: tur }))}</span>
1184    <span style="opacity:.8;">${escapeHtml(t('gcall.ongoing_count', { count: cagri.joined_count || 0 }))}</span>
1185    <button id="chatGCallJoin" style="margin-left:auto;border:0;border-radius:8px;
1186      padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;
1187      background: #17884D;color:#fff;">${escapeHtml(t('gcall.join'))}</button>`;
1188  el.querySelector('#chatGCallJoin').onclick = async () => {
1189    const mod = await import('./grup-arama.js');
1190    mod.katil(cagri.id, { video: cagri.kind === 'video', baslik: convInfo.title || '' });
1191  };
1192}
1193
1194async function _grupSeridiniTazele(convInfo) {
1195  if (!api.token) return;
1196  try {
1197    const r = await api.get(`/api/group-calls/active?conversation_id=${convInfo.id}`,
1198                            { sessiz: true });
1199    // Sohbet bu arada DEĞİŞMİŞ olabilir: yavaş bir yanıt, yeni açılan başka
1200    // bir sohbete alakasız bir şerit çizerdi.
1201    if (String(state.conversationId) !== String(convInfo.id)) return;
1202    if (r && r.call) _grupSeridiniCiz(convInfo, r.call);
1203    else _grupSeridiniKaldir();
1204  } catch { _grupSeridiniKaldir(); }
1205}
1206
1207// Görüşme başladı/bitti olayı: açık olan sohbetin şeridi ANINDA güncellensin.
1208// Yalnız açılışta sorsaydık, sohbeti açık tutan biri görüşmeden hiç haberdar
1209// olmazdı.
1210window.addEventListener('morfoz:gcall-state', (ev) => {
1211  const d = ev.detail || {};
1212  const cagri = d.call;
1213  if (!cagri || state.activeType !== 'group') return;
1214  if (String(cagri.conversation_id) !== String(state.conversationId)) return;
1215  const convInfo = state.activeConvInfo || { id: state.conversationId };
1216  if (d.type === 'gcall.ended' || cagri.status !== 'active') _grupSeridiniKaldir();
1217  else _grupSeridiniCiz(convInfo, cagri);
1218});
1219
1220// ---------- Güvenlik: tanımadık kişi şeridi + engel/şikâyet sonrası ----------
1221
1222/** Şeridi AÇIK SOHBETE göre kur. `hala` kapısı: yavaş dönen `/guvenlik`
1223 *  yanıtı, kullanıcı bu arada başka sohbete geçtiyse hiçbir şey çizmez. */
1224function _guvenlikSeridiniYukle(convInfo) {
1225  dmGuvenligiYukle(convInfo, {
1226    hala: () => state.conversationId === convInfo.id,
1227    degisti: () => _guvenlikDegisti(convInfo),
1228  }).catch(() => { /* kütüphane zaten yutuyor; sohbet etkilenmesin */ });
1229}
1230
1231/**
1232 * Engelle / engeli kaldır / şikâyet SONRASI: şerit sunucudaki yeni duruma
1233 * göre yeniden kurulur ve sol liste tazelenir (engellenen kişinin DM'si
1234 * listeden düşüyor — sunucu süzüyor). Mesajlar yeniden çizilmiyor: açık
1235 * ekranı kullanıcının elinden çekmek, az önce okuduğu kanıtı silmek olurdu.
1236 */
1237function _guvenlikDegisti(convInfo) {
1238  if (convInfo && state.conversationId === convInfo.id) _guvenlikSeridiniYukle(convInfo);
1239  window.morfozRefreshChatList && window.morfozRefreshChatList();
1240}
1241
1242// ---------- Grup bilgi / yönetim paneli ----------
1243function _wireGroupInfoButton(convInfo) {
1244  const headerActions = document.querySelector('#chatView .header-actions');
1245  if (!headerActions) return;
1246  let btn = document.getElementById('chatGroupInfoBtn');
1247  if (!btn) {
1248    btn = document.createElement('button');
1249    btn.id = 'chatGroupInfoBtn';
1250    btn.className = 'icon-btn';
1251    btn.title = t('chat.conv_info');
1252    btn.setAttribute('aria-label', t('chat.conv_info'));
1253    btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="7" r="4"/><path d="M2 21v-1a6 6 0 0 1 6-6h2a6 6 0 0 1 6 6v1"/><circle cx="18" cy="8" r="3"/><path d="M22 21v-1a4.5 4.5 0 0 0-3-4.24"/></svg>';
1254    headerActions.prepend(btn);
1255  }
1256  btn.style.display = 'inline-flex';
1257  btn.onclick = () => _openGroupInfoModal(convInfo);
1258  // Kaybolan mesaj süresi AÇIKSA başlıkta saat ipucu yansın. Kullanıcı yazmaya
1259  // başlamadan ÖNCE görmeli: yazdıktan sonra öğrenmek geç olurdu.
1260  api.get(`/api/conversations/${convInfo.id}/disappearing`)
1261    .then(a => _kaybolmaIpucu(a.seconds, a.label))
1262    .catch(() => _kaybolmaIpucu(0));
1263}
1264
1265/** Başlıktaki "⏱ 7 gün" rozeti. seconds=0 → rozet kaldırılır. */
1266function _kaybolmaIpucu(seconds, etiket = '') {
1267  const header = document.querySelector('#chatView .header-info h1');
1268  if (!header) return;
1269  header.querySelector('.km-rozet')?.remove();
1270  const sn = Number(seconds) || 0;
1271  if (sn <= 0) return;
1272  const el = document.createElement('span');
1273  el.className = 'km-rozet';
1274  el.textContent = `⏱ ${etiket || _sureEtiketi(sn)}`;
1275  el.title = t('chat.disappear.header_title');
1276  el.style.cssText = 'margin-left:8px;font-size:9.5px;font-weight:700;border-radius:5px;'
1277    + 'padding:1px 5px;vertical-align:middle;color:var(--text-primary, #4C3A78);background:rgba(107,35,135,.10);'
1278    + 'border:1px solid rgba(107,35,135,.22);';
1279  header.appendChild(el);
1280}
1281
1282/** Sunucunun sunduğu üç süre + "kapalı". Etiketler ÇEVRİLİYOR: sunucu Türkçe
1283 *  ad da gönderiyor ama İngilizce panelde "7 gün" yazması sızıntı olurdu. */
1284const KAYBOLMA_SURELERI = [0, 86400, 604800, 7776000];
1285function _sureEtiketi(sn) {
1286  const n = Number(sn) || 0;
1287  if (n <= 0) return t('chat.disappear.off');
1288  if (n === 86400) return t('chat.disappear.24h');
1289  if (n === 604800) return t('chat.disappear.7d');
1290  if (n === 7776000) return t('chat.disappear.90d');
1291  return t('chat.disappear.custom', { count: Math.round(n / 3600) });
1292}
1293
1294/** Sohbet bilgisi ekranının stilleri. Satır içi yerine sınıf: aynı görünüm
1295 *  hem kişi kartında hem üye satırında kullanılıyor ve renkler DEĞİŞKENDEN
1296 *  geliyor — bu ekranda bugün üç kez sabit renk yüzünden okunamama yaşandı. */
1297function _sohbetBilgisiStilleri() {
1298  if (document.getElementById('sohbetBilgisiCSS')) return;
1299  const s = document.createElement('style');
1300  s.id = 'sohbetBilgisiCSS';
1301  s.textContent = `
1302  .gi-h{font-size:12px;font-weight:700;color:var(--text-secondary,#6B5F7E);
1303    text-transform:uppercase;letter-spacing:.04em;margin:14px 0 4px;}
1304  .gi-sub{font-size:11.5px;color:var(--text-secondary,#6B5F7E);margin-top:2px;}
1305  .gi-note{font-size:11.5px;color:var(--text-secondary,#6B5F7E);font-style:italic;
1306    margin-top:3px;overflow:hidden;text-overflow:ellipsis;}
1307  .gi-on{color:var(--success,#2A9D7C);font-weight:600;}
1308  .gi-off{color:var(--text-secondary,#6B5F7E);}
1309  /* Avatarın üstündeki yeşil nokta: zeminden AYRILSIN diye çevresinde halka var,
1310     yoksa koyu avatar tonlarında kayboluyor. */
1311  .gi-dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;
1312    border-radius:50%;background:var(--success,#2A9D7C);
1313    box-shadow:0 0 0 2px var(--surface-1,#fff);}
1314  .gi-dot-lg{width:16px;height:16px;right:2px;bottom:2px;box-shadow:0 0 0 3px var(--surface-1,#fff);}
1315  .gi-tag{font-size:9.5px;border-radius:5px;padding:1px 5px;margin-left:5px;
1316    background:var(--surface-2,#F3EEF8);color:var(--text-secondary,#6B5F7E);font-weight:700;}
1317  .gi-tag-owner{color:#8E6A00;background:#FFF3D6;}
1318  .gi-tag-admin{color:#3C2A78;background:#E7E1FB;}
1319  .gi-role-btn{border:none;background:transparent;color:var(--brand,#6B2387);
1319cursor:pointer;
1320    font:inherit;font-size:11.5px;font-weight:600;white-space:nowrap;}
1321  .gi-role-btn:disabled{opacity:.5;cursor:default;}
1322  .gi-card{display:flex;flex-direction:column;align-items:center;text-align:center;
1323    padding:14px 8px 4px;}
1324  .gi-blocked{margin-top:9px;font-size:12px;font-weight:600;color:var(--danger, #B83838);
1325    background:rgba(184,56,56,.08);border-radius:8px;padding:5px 10px;}
1326  .gi-grp{font-size:12.5px;padding:7px 6px;border-bottom:1px solid var(--border,rgba(58,28,89,.08));
1327    cursor:pointer;border-radius:6px;}
1328  .gi-grp:hover{background:var(--surface-2,#F3EEF8);}
1329  .gi-notes{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:5px 0 1px 40px;
1330    font-size:11.5px;font-weight:600;cursor:pointer;}
1331  .gi-notes input{margin:0;cursor:pointer;}
1332  .gi-notes-hint{font-weight:400;font-size:10.5px;color:var(--text-tertiary,#A89DB5);}
1333  /* Eğitim izni bölümü. Yalnız panelin İKİ TEMADA DA tanımlı jetonları
1334     kullanılıyor; yedekler açık temaya göre. (Takip ekranı bir dönem
1335     cagri.html'in --zemin/--kabartma jetonlarını kullanmış ve panelde
1336     çözülemediği için kutular siyah çizilmişti.) */
1337  .gi-iz-row{display:flex;align-items:center;gap:8px;padding:7px 4px;
1338    border-bottom:1px solid var(--border,rgba(58,28,89,.08));}
1339  .gi-iz-ad{flex:1;min-width:0;font-size:13px;font-weight:600;
1340    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1341  .gi-iz{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:100px;
1342    white-space:nowrap;}
1343  .gi-iz-onayladi{background:color-mix(in srgb,var(--success,#2A9D7C) 16%,transparent);
1344    color:var(--success,#2A9D7C);}
1345  .gi-iz-reddetti{background:color-mix(in srgb,var(--danger,#B83838) 14%,transparent);
1346    color:var(--danger,#B83838);}
1347  .gi-iz-bekliyor{background:color-mix(in srgb,var(--warning,#D99434) 18%,transparent);
1348    color:var(--warning,#D99434);}
1349  .gi-iz-sorulmadi{background:var(--bg-soft,#E9E2F0);color:var(--text-tertiary,#A89DB5);}
1350  .gi-iz-btn{border:1px solid var(--border-strong,rgba(58,28,89,.14));
1351    background:transparent;color:var(--brand,#6B2387);border-radius:8px;
1352    padding:3px 9px;font:inherit;font-size:11px;font-weight:600;cursor:pointer;}
1353  .gi-iz-btn[disabled]{opacity:.45;cursor:default;color:var(--text-tertiary,#A89DB5);}
1354  .gi-iz-ozet{font-size:11.5px;line-height:1.45;margin:2px 0 6px;
1355    color:var(--text-tertiary,#A89DB5);}
1356  .gi-iz-ozet.acik{color:var(--success,#2A9D7C);font-weight:600;}
1357  `;
1358  document.head.appendChild(s);
1359}
1360
1361async function _openGroupInfoModal(convInfo) {
1362  document.getElementById('groupInfoModal')?.remove();
1363  let data;
1364  try {
1365    data = await api.get(`/api/conversations/${convInfo.id}/participants`);
1366  } catch (e) { toast(t('chat.info_failed', { error: e.message }), 'error'); return; }
1367  const isAdmin = data.my_role === 'owner' || data.my_role === 'admin';
1368  const isGroup = (convInfo.type || state.activeType) === 'group';
1369  // Kaybolan mesaj ayarı. Katılımcı listesiyle AYNI pencerede duruyor çünkü
1370  // ikisi de "bu konuşma nasıl işliyor" sorusunun parçası. Hata yutuluyor:
1371  // ayar okunamazsa bilgi ekranı yine açılmalı (kapalı varsayılır).
1372  let kaybolma = { seconds: 0 };
1373  try { kaybolma = await api.get(`/api/conversations/${convInfo.id}/disappearing`); } catch {}
1374  // EĞİTİM İZNİ DURUMU. Döküm ancak HERKES onaylayınca açılıyor (bkz.
1375  // services/takip_dokum) ama isteyen kişi kimin eksik olduğunu göremiyordu:
1376  // tek bir kişinin cevapsızlığı bütün özelliği sessizce kapatıyor ve
1377  // "neden çalışmıyor" sorusunun cevabı hiçbir ekranda yazmıyordu.
1378  // Hata YUTULUYOR: bu bölüm okunamazsa bilgi ekranı yine açılmalı.
1379  let izin = null;
1380  try { izin = await api.get(`/api/takip/conversations/${convInfo.id}/durum`); } catch {}
1381  // BİRE BİR: "Engelle / Engeli kaldır" kararı için BENİM koyduğum engel.
1382  // `peer.blocked` iki yönlü (karşı taraf beni engellediyse de true) — onunla
1383  // "Engeli kaldır" göstermek hiçbir şey kaldırmayan bir düğme olurdu. Uç
1384  // yoksa (eski sunucu) null döner ve `peer.blocked`a düşülür.
1385  const guv = (!isGroup && data.peer) ? await guvenlikOzeti(convInfo.id) : null;
1386  const benEngelledim = guv ? !!guv.engelli : !!(data.peer && data.peer.blocked);
1387
1388  const ov = document.createElement('div');
1389  ov.id = 'groupInfoModal';
1390  ov.style.cssText = 'position:fixed;inset:0;z-index:2200;background:rgba(20,10,40,.45);display:flex;align-items:center;justify-content:center;padding:16px;';
1391  // ÜYE SATIRI ARTIK DURUM TAŞIYOR. Önce yalnız ad + @handle vardı; liste
1392  // "kimler var" sorusuna cevap veriyordu ama kullanıcının sorduğu "şu an
1393  // ulaşabilir miyim, en son ne zaman buradaydı" sorusuna hiçbir şey demiyordu.
1394  // Görünürlük kararını SUNUCU veriyor (services/presence): engellenmiş,
1395  // "Görünmez" seçmiş ya da son görülmesini kapatmış kişide alanlar boş gelir
1396  // ve burada da hiçbir şey yazmayız — TAHMİN ÜRETMEYİZ.
1397  const _durumSatiri = (u) => {
1398    if (u.online) return `<span class="gi-on">${escapeHtml(t('chat.online'))}</span>`;
1399    if (u.last_seen_at) return `<span class="gi-off">${t('chat.last_seen', { time: escapeHtml(presence.sonGorulmeMetni(u.last_seen_at)) })}</span>`;
1400    return '';
1401  };
1402  // Yönetici atama/geri alma kuralları SUNUCUYLA BİREBİR aynı (bkz.
1403  // set_participant_role): yükseltmeyi kurucu ve yöneticiler yapar, geri almayı
1404  // yalnız kurucu — bir yönetici de yalnız kendi yöneticiliğini bırakabilir.
1405  // Buradaki tek iş düğmeyi göstermemek; yetkiyi yine sunucu karara bağlıyor.
1406  const _rolDugmesi = (u) => {
1407    if (!isGroup || u.role === 'owner') return '';
1408    if (u.role === 'admin') {
1409      const kendim = u.is_me;
1410      if (data.my_role !== 'owner' && !kendim) return '';
1411      return `<button data-role-user="${u.id}" data-role-to="member" class="gi-role-btn">${escapeHtml(t(kendim ? 'chat.info.step_down' : 'chat.info.unmake_admin'))}</button>`;
1412    }
1413    if (!isAdmin || u.is_me) return '';
1414    return `<button data-role-user="${u.id}" data-role-to="admin" data-role-name="${escapeHtml(u.name || '')}" class="gi-role-btn">${escapeHtml(t('chat.info.make_admin'))}</button>`;
1415  };
1416  const userRows = (data.users || []).map(u => `
1417    <div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border,rgba(58,28,89,.08));">
1418      <div class="avatar tone-${u.avatar_tone || 2}" style="width:34px;height:34px;font-size:13px;position:relative;cursor:pointer;"${_kartNitelik(u, u.name)}>${_balonAvatarIci(u, (u.name || '?').slice(0, 1).toUpperCase())}${u.online ? '<span class="gi-dot"></span>' : ''}</div>
1419      <div style="flex:1;min-width:0;">
1420        <div style="font-size:13px;font-weight:600;">${escapeHtml(u.name)}${u.is_me ? `<span class="gi-tag">${escapeHtml(t('chat.tag_you'))}</span>` : ''}${u.role === 'owner' ? `<span class="gi-tag gi-tag-owner">${escapeHtml(t('chat.tag_founder'))}</span>` : ''}${u.role === 'admin' ? `<span class="gi-tag gi-tag-admin">${escapeHtml(t('chat.tag_admin'))}</span>` : ''}</div>
1421        <div class="gi-sub">${u.handle ? `@${escapeHtml(u.handle)}` : ''}${u.handle && _durumSatiri(u) ? ' · ' : ''}${_durumSatiri(u)}</div>
1422        ${u.status_message ? `<div class="gi-note">“${escapeHtml(u.status_message)}”</div>` : ''}
1423      </div>
1424      ${_rolDugmesi(u)}
1425      ${isGroup && isAdmin && u.role !== 'owner'
1426        // Yöneticiyi yalnız kurucu çıkarabilir (sunucu da böyle karar veriyor);
1427        // düğmeyi göstermek kullanıcıya 403'ten başka bir şey vermezdi.
1428        && !(u.role === 'admin' && data.my_role !== 'owner' && !u.is_me) ? `<button data-rm-user="${u.id}" style="border:none;background:transparent;color:var(--danger, #B83838);cursor:pointer;font-size:11.5px;font-weight:600;">${escapeHtml(t('chat.remove'))}</button>` : ''}
1429    </div>`).join('');
1430
1431  // BİRE BİR: "üye listesi" yerine KİŞİ KARTI. İki kişilik bir listede kimin
1432  // kim olduğunu göstermek bilgi vermiyordu; burada karşı taraf tanıtılıyor.
1433  const p = data.peer;
1434  const peerCard = (!isGroup && p) ? `
1435    <div class="gi-card">
1436      <div class="avatar tone-${p.avatar_tone || 2}" style="width:64px;height:64px;font-size:24px;position:relative;cursor:pointer;"${_kartNitelik(p, p.name)}>${_balonAvatarIci(p, (p.name || '?').slice(0, 1).toUpperCase())}${p.online ? '<span class="gi-dot gi-dot-lg"></span>' : ''}</div>
1437      <div style="font-size:17px;font-weight:700;margin-top:9px;">${escapeHtml(p.name)}</div>
1438      ${p.handle ? `<div class="gi-sub" style="margin-top:2px;">@${escapeHtml(p.handle)}</div>` : ''}
1439      <div style="margin-top:5px;font-size:12.5px;">${_durumSatiri(p) || `<span class="gi-off">${escapeHtml(t('chat.info.status_hidden'))}</span>`}</div>
1440      ${p.status_message ? `<div class="gi-note" style="margin-top:7px;font-size:12.5px;">“${escapeHtml(p.status_message)}”</div>` : ''}
1441      ${benEngelledim ? `<div class="gi-blocked">${escapeHtml(t('chat.info.blocked'))}</div>` : ''}
1442    </div>
1443    ${(p.ortak_gruplar || []).length ? `
1444      <div class="gi-h">${escapeHtml(t('chat.info.common_groups', { count: p.ortak_gruplar.length }))}</div>
1445      <div>${p.ortak_gruplar.map(g => `
1446        <div class="gi-grp" data-open-conv="${g.id}">👥 ${escapeHtml(g.title || t('chat.group'))}</div>`).join('')}</div>` : ''}
1447  ` : '';
1448  const agentRows = (data.agents || []).map(a => `
1449    <div style="padding:7px 4px;border-bottom:1px solid var(--border,rgba(58,28,89,.08));">
1450    <div style="display:flex;align-items:center;gap:10px;">
1451      <div class="avatar tone-${a.avatar_tone || 2}" style="width:30px;height:30px;font-size:12px;cursor:pointer;"${_kartNitelik({ ...a, kind: 'assistant' }, a.name)}>🤖</div>
1452      <div style="flex:1;min-width:0;">
1453        <div style="font-size:13px;font-weight:600;">${escapeHtml(a.name)}</div>
1454        <div style="font-size:11px;color:var(--text-tertiary,#A89DB5);">
1455          ${escapeHtml(t('chat.info.agent_reply', {
1456            mode: a.reply_mode === 'off' ? t('chat.info.mode_off')
1457              : a.reply_mode === 'mention_only' ? t('chat.info.mode_mention')
1458              : a.reply_mode === 'all' ? t('chat.info.mode_handover')
1459              : t('chat.info.mode_smart'),
1460          }))}
1461          · ${escapeHtml(t('chat.info.agent_digest', { state: a.digest_enabled ? t('chat.info.on') : t('chat.info.off') }))}
1462          · ${escapeHtml(t('chat.info.agent_alert', { state: a.alert_enabled ? t('chat.info.on') : t('chat.info.off') }))}
1463        </div>
1464      </div>
1465      ${a.is_mine ? `
1466        <button data-handover="${a.id}" data-on="${a.reply_mode === 'all' ? '0' : '1'}"
1467          style="border:1px solid ${a.reply_mode === 'all' ? '#B83838' : 'var(--brand,#6B2387)'};
1468                 background:${a.reply_mode === 'all' ? 'rgba(184,56,56,.08)' : 'transparent'};
1469                 color:${a.reply_mode === 'all' ? '#B83838' : 'var(--brand,#6B2387)'};
1470                 border-radius:8px;padding:3px 8px;font:inherit;font-size:11px;font-weight:600;cursor:pointer;">
1471          ${escapeHtml(a.reply_mode === 'all' ? t('chat.info.handover_off') : t('chat.info.handover_on'))}
1472        </button>` : ''}
1473      <select data-agent-mode="${a.id}" style="font:inherit;font-size:11px;border:1px solid var(--border);border-radius:8px;padding:3px 5px;background:var(--surface-1,#fff);color:inherit;">
1474        <option value="smart" ${a.reply_mode === 'smart' ? 'selected' : ''}>${escapeHtml(t('chat.info.opt_smart'))}</option>
1475        <option value="mention_only" ${a.reply_mode === 'mention_only' ? 'selected' : ''}>${escapeHtml(t('chat.info.opt_mention'))}</option>
1476        <option value="off" ${a.reply_mode === 'off' ? 'selected' : ''}>${escapeHtml(t('chat.info.opt_silent'))}</option>
1477      </select>
1478      <button data-rm-agent="${a.id}" style="border:none;background:transparent;color:var(--danger, #B83838);cursor:pointer;font-size:11.5px;font-weight:600;">${escapeHtml(t('chat.remove'))}</button>
1479    </div>
1480    <!-- TOPLANTI NOTU KENDİ SATIRINDA: üstteki şerit (devret + kip + çıkar)
1481         zaten dolu; dördüncü bir denetim eklemek dar ekranda taşırdı. -->
1482    <label class="gi-notes">
1483      <input type="checkbox" data-agent-notes="${a.id}" ${a.notes_enabled ? 'checked' : ''}>
1484      <span>📝 ${escapeHtml(t('meet.agent_notes_toggle'))}</span>
1485      <span class="gi-notes-hint">${escapeHtml(t('meet.agent_notes_hint'))}</span>
1486    </label>
1487    </div>`).join('');
1488
1489  // EĞİTİM İZNİ BÖLÜMÜ. Satırlar YALNIZ benim sorduklarımı gösteriyor —
1490  // sunucu `isteyen_id = ben` ile süzüyor, başkasının kime sorup ne cevap
1491  // aldığı burada görünmez.
1492  const _izinRozet = (d) => `<span class="gi-iz gi-iz-${d}">${
1493    escapeHtml(t('takip.durum.' + d))}</span>`;
1494  const _izinDugme = (r) => {
1495    if (r.durum === 'onayladi') return '';
1496    const etiket = t(r.durum === 'sorulmadi'
1497      ? 'takip.durum.iste' : 'takip.durum.tekrar');
1498    if (r.tekrar) {
1499      return `<button data-izin-tekrar="${r.user_id}" class="gi-iz-btn">${
1500        escapeHtml(etiket)}</button>`;
1501    }
1502    // FREN GÖRÜNÜR OLMALI. Düğmeyi sessizce silmek "neden yok" sorusunu
1503    // doğururdu; sebebi (bekleme / hak bitti) ipucunda yazıyor.
1504    const ipucu = r.tekrar_sebep === 'tavan'
1505      ? t('takip.durum.tavan')
1506      : t('takip.durum.bekle', { days: r.kalan_gun || 0 });
1507    return `<button class="gi-iz-btn" disabled title="${escapeHtml(ipucu)}">${
1508      escapeHtml(etiket)}</button>`;
1509  };
1510  const izinRows = ((izin && izin.katilimcilar) || []).map(r => `
1511    <div class="gi-iz-row">
1512      <span class="gi-iz-ad">${escapeHtml(r.ad)}${r.ben
1513        ? `<span class="gi-tag">${escapeHtml(t('chat.tag_you'))}</span>` : ''}</span>
1514      ${_izinRozet(r.durum)}
1515      ${r.ben ? '' : _izinDugme(r)}
1516    </div>`).join('');
1517  const izinBolumu = (izin && izinRows) ? `
1518    <div class="gi-h">🎓 ${escapeHtml(t('takip.durum.baslik'))}</div>
1519    <div class="gi-iz-ozet${izin.karsilikli ? ' acik' : ''}">${escapeHtml(
1520      t(izin.karsilikli ? 'takip.durum.acik' : 'takip.durum.kapali'))}</div>
1521    <div>${izinRows}</div>` : '';
1522
1523  ov.innerHTML = `
1524    <div style="background:var(--surface-1,#fff);color:var(--text-primary,#1F1430);border-radius:16px;max-width:440px;width:100%;max-height:86vh;overflow:auto;padding:18px;box-shadow:0 18px 50px rgba(20,10,40,.35);">
1525      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
1526        <div style="font-size:15px;font-weight:700;">${isGroup ? `👥 ${escapeHtml(t('chat.info.group_title'))}` : `💬 ${escapeHtml(t('chat.conv_info'))}`}</div>
1527        <button id="giClose" style="border:none;background:transparent;font-size:20px;cursor:pointer;color:inherit;">×</button>
1528      </div>
1529      ${peerCard}
1530      ${isGroup ? `
1531        <div class="gi-h">${escapeHtml(t('chat.info.members', { count: (data.users || []).length }))}</div>
1532        <div>${userRows || `<div style="font-size:12px;color:#A89DB5;padding:6px;">${escapeHtml(t('chat.info.no_members'))}</div>`}</div>` : ''}
1533      ${isGroup && isAdmin ? `<button id="giAddMember" style="margin-top:8px;border:1px dashed var(--border);background:transparent;color:var(--brand,#6B2387);border-radius:10px;padding:7px 12px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;width:100%;">+ ${escapeHtml(t('chat.info.add_member'))}</button>` : ''}
1534      <!-- TOKUŞLA EKLE — yanındaki kişiyi aramadan, kod söyleyerek gruba alma
1535           (services/tokus_baglam, tur 'sohbet_grubu'). Yalnız kurucu/yönetici
1536           görüyor: sunucu da aynı kapıyı uyguluyor ve düğmeyi herkese
1537           göstermek kullanıcıya 'yetkisiz' sonucundan başka bir şey vermezdi. -->
1538      ${isGroup && isAdmin ? `<button id="giTokusMember" style="margin-top:6px;border:1px solid var(--border);background:transparent;color:var(--brand,#6B2387);border-radius:10px;padding:7px 12px;font:inherit;font-size:12px;font-weight:600;cursor:pointer;width:100%;">${escapeHtml(t('chat.info.add_member_bump'))}</button>` : ''}
1539      ${izinBolumu}
1540      <div class="gi-h">⏱ ${escapeHtml(t('chat.disappear.title'))}</div>
1541      <select id="giKaybolma" style="width:100%;font:inherit;font-size:12.5px;padding:7px 9px;border:1px solid var(--border);border-radius:9px;background:var(--surface-1,#fff);color:inherit;">
1542        ${KAYBOLMA_SURELERI.map(sn => `<option value="${sn}" ${Number(kaybolma.seconds || 0) === sn ? 'selected' : ''}>${escapeHtml(_sureEtiketi(sn))}</option>`).join('')}
1543      </select>
1544      <div style="font-size:10.5px;color:var(--text-tertiary,#A89DB5);margin-top:5px;line-height:1.45;">
1545        ${escapeHtml(t('chat.disappear.note'))}
1546      </div>
1547      <div style="font-size:12px;font-weight:700;color:var(--text-tertiary,#8A7E9B);text-transform:uppercase;letter-spacing:.04em;margin:14px 0 4px;">${escapeHtml(t('chat.info.agents', { count: (data.agents || []).length }))}</div>
1548      <div>${agentRows || `<div style="font-size:12px;color:#A89DB5;padding:6px;">${escapeHtml(t('chat.info.no_agents'))}</div>`}</div>
1549      <button id="giInviteAgent" style="margin-top:8px;border:1px dashed var(--border);background:transparent;color:var(--brand,#6B2387);border-radius:10px;padding:7px 12px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;width:100%;">🤖 ${escapeHtml(t('chat.info.invite_agent'))}</button>
1550      <div style="font-size:10.5px;color:var(--text-tertiary,#A89DB5);margin-top:6px;line-height:1.45;">
1551        ${escapeHtml(t('chat.info.agent_note'))}
1552      </div>
1553      ${isGroup ? `<button id="giLeave" style="margin-top:14px;border:1px solid rgba(184,56,56,.35);background:rgba(184,56,56,.06);color:var(--danger, #B83838);border-radius:10px;padding:8px 12px;font:inherit;font-size:12.5px;font-weight:700;cursor:pointer;width:100%;">${escapeHtml(t('chat.info.leave_group'))}</button>` : ''}
1554      ${(!isGroup && p && p.id) ? dmGuvenlikBolumuHtml({ engelli: benEngelledim }) : ''}
1555    </div>
1556  `;
1557  _sohbetBilgisiStilleri();
1558  document.body.appendChild(ov);
1559  ov.addEventListener('click', (e) => { if (e.target === ov) ov.remove(); });
1560  ov.querySelector('#giClose').addEventListener('click', () => ov.remove());
1561
1562  // KİŞİYİ ENGELLE / ENGELİ KALDIR / ŞİKÂYET ET (yalnız bire bir). Durum
1563  // değiştiyse pencere yeniden açılıyor — düğme etiketi ve "engelledin"
1564  // rozeti sunucudan gelsin, elle çevirmek iki doğruluk kaynağı demekti.
1565  if (!isGroup && p && p.id) {
1566    dmGuvenlikBolumuBagla(ov, {
1567      kisiId: p.id, kisiAd: p.name || '', engelli: benEngelledim,
1568      sonra: (r) => {
1569        if (!r || !(r.engellendi || r.engelKalkti)) return;
1570        _guvenlikDegisti(convInfo);
1571        if (!document.body.contains(ov)) return;
1572        ov.remove();
1573        _openGroupInfoModal(convInfo).catch(() => {});
1574      },
1575    });
1576  }
1577
1578  // Ortak gruba tıklayınca o gruba geç — bilgi ekranı çıkmaz sokak olmasın.
1579  ov.querySelectorAll('[data-open-conv]').forEach(el => el.addEventListener('click', () => {
1580    ov.remove();
1581    location.hash = `#messages/${el.dataset.openConv}`;
1582  }));
1583
1584  // "Tekrar iste" — tek kişiye izni yeniden sor. Başarıda ekran yeniden
1585  // çiziliyor: rozet ve düğme durumu sunucudan gelir, elle güncellemek
1586  // bekleme/tavan hesabını istemcide ikinci kez kurmak olurdu.
1587  ov.querySelectorAll('[data-izin-tekrar]').forEach(b => b.addEventListener('click', async () => {
1588    const eski = b.textContent;
1589    b.disabled = true;
1590    b.textContent = t('takip.durum.gonderiliyor');
1591    try {
1592      await api.post(
1593        `/api/takip/conversations/${convInfo.id}/tekrar/${b.dataset.izinTekrar}`, {});
1594      toast(t('takip.durum.gonderildi'));
1595      ov.remove();
1596      _openGroupInfoModal(convInfo);
1597    } catch (e) {
1598      toast(e.message, 'error');
1599      b.disabled = false;
1600      b.textContent = eski;
1601    }
1602  }));
1603
1604  ov.querySelectorAll('[data-rm-user]').forEach(b => b.addEventListener('click', async () => {
1605    try {
1606      await api.del(`/api/conversations/${convInfo.id}/participants/${b.dataset.rmUser}`);
1607      ov.remove(); _openGroupInfoModal(convInfo);
1608    } catch (e) { toast(e.message, 'error'); }
1609  }));
1610  ov.querySelectorAll('[data-role-user]').forEach(b => b.addEventListener('click', async () => {
1611    const hedef = b.dataset.roleTo;
1612    if (hedef === 'admin' && !confirm(t('chat.info.make_admin_confirm', { name: b.dataset.roleName || '' }))) return;
1613    b.disabled = true;
1614    try {
1615      await api.patch(`/api/conversations/${convInfo.id}/participants/${b.dataset.roleUser}`,
1616        { role: hedef });
1617      toast(t('chat.info.role_updated'), 'success');
1618      ov.remove(); _openGroupInfoModal(convInfo);
1619    } catch (e) { b.disabled = false; toast(e.message, 'error'); }
1620  }));
1621  ov.querySelectorAll('[data-rm-agent]').forEach(b => b.addEventListener('click', async () => {
1622    try {
1623      await api.del(`/api/conversations/${convInfo.id}/agents/${b.dataset.rmAgent}`);
1624      ov.remove(); _openGroupInfoModal(convInfo);
1625    } catch (e) { toast(e.message, 'error'); }
1626  }));
1627  // DEVRET / GERİ AL — yalnız klonun sahibine gösteriliyor (sunucu da öyle
1628  // doğruluyor). Devir açıkken klon her mesaja cevap verir ve "sahibim
1629  // çevrimiçiyken sus" kuralı devre dışı kalır.
1630  ov.querySelectorAll('[data-handover]').forEach(b => b.addEventListener('click', async () => {
1631    b.disabled = true;
1632    try {
1633      await api.post(`/api/conversations/${convInfo.id}/agents/${b.dataset.handover}/handover`,
1634        { on: b.dataset.on === '1' });
1635      toast(b.dataset.on === '1' ? t('chat.info.handover_done') : t('chat.info.handover_undone'), 'success');
1636      ov.remove(); _openGroupInfoModal(convInfo);
1637    } catch (e) { b.disabled = false; toast(e.message, 'error'); }
1638  }));
1639  // TOPLANTI NOTU AÇ/KAPA. Bu kutu, sesli/görüntülü görüşmede her katılımcının
1640  // tarayıcısının kendi mikrofonunu metne çevirmeye başlamasının TEK anahtarı
1641  // (bkz. grup-arama.js not modülü) — sunucu kararı çağrı gövdesinde
1642  // `notetaker` olarak yayıyor ve odadaki herkes rozeti görüyor.
1643  ov.querySelectorAll('[data-agent-notes]').forEach(cb => cb.addEventListener('change', async () => {
1644    const acik = cb.checked;
1645    cb.disabled = true;
1646    try {
1647      await api.patch(`/api/conversations/${convInfo.id}/agents/${cb.dataset.agentNotes}`,
1648        { notes_enabled: acik });
1649      toast(acik ? t('meet.notes_on_toast') : t('meet.notes_off_toast'), 'success');
1650    } catch (e) {
1651      // Sunucu reddettiyse (yetki yok) kutu GERÇEĞİ göstermeli — açık kalırsa
1652      // kullanıcı not tutulduğunu sanır ve hiç tutulmaz.
1653      cb.checked = !acik;
1654      toast(e.message, 'error');
1655    } finally { cb.disabled = false; }
1656  }));
1657  ov.querySelectorAll('[data-agent-mode]').forEach(sel => sel.addEventListener('change', async () => {
1658    try {
1659      await api.patch(`/api/conversations/${convInfo.id}/agents/${sel.dataset.agentMode}`,
1660        { reply_mode: sel.value });
1661      toast(t('chat.info.agent_updated'), 'success');
1662    } catch (e) { toast(e.message, 'error'); }
1663  }));
1664  ov.querySelector('#giAddMember')?.addEventListener('click', async () => {
1665    const { openUserSearchModal } = await import('./user-picker.js');
1666    openUserSearchModal({
1667      already: (data.users || []).map(u => u.id),
1668      onPick: async (id, userRow) => {
1669        try {
1670          await api.post(`/api/conversations/${convInfo.id}/participants`, { user_id: id });
1671          toast(t('chat.info.member_added', {
1672            name: (userRow && (userRow.full_name || userRow.handle)) || t('panel.member'),
1673          }), 'success');
1674          // Üye listesini ANINDA tazele. "Çıkar" bunu yapıyordu, ekleme
1675          // yapmıyordu: eklenen kişi listede görünmüyor, kullanıcı modalı
1676          // kapatıp açmak zorunda kalıyordu (ve `already` bayat kaldığı için
1677          // aynı kişi tekrar seçilebiliyordu).
1678          ov.remove();
1679          _openGroupInfoModal(convInfo);
1680        } catch (e) { toast(e.message, 'error'); }
1681      },
1682    });
1683  });
1684  // TOKUŞLA EKLE. Bilgi penceresi KAPANIYOR ve katman kapanınca YENİDEN
1685  // açılıyor: üye listesi tokuşla değişiyor ve arkada duran bayat bir liste,
1686  // "ekledim ama görünmüyor" izlenimi verirdi (yukarıdaki ekleme yolunun
1687  // öğrendiği aynı ders).
1688  ov.querySelector('#giTokusMember')?.addEventListener('click', async () => {
1689    try {
1690      const { tokusKatmaniAc } = await import('./oyun-puan.js');
1691      ov.remove();
1692      tokusKatmaniAc({
1693        baglam: { tur: 'sohbet_grubu', id: Number(convInfo.id) },
1694        // `.catch` ŞART: `kapandi` katmanın kapatıcısından SENKRON çağrılıyor
1695        // ve oradaki try/catch bir söz reddini yakalayamaz — pencere yeniden
1696        // açılamazsa (istek düştü) konsola yakalanmamış ret basılırdı.
1697        kapandi: () => { _openGroupInfoModal(convInfo).catch(() => {}); },
1698      });
1699    } catch (e) { toast(e.message || t('chat.info.bump_failed'), 'error'); }
1700  });
1701  // Süre değişimi konuşmaya sistem mesajı olarak düşüyor (sunucu yazıyor) —
1702  // burada yalnız başlık rozeti tazeleniyor, ekrana ikinci bir bildirim
1703  // basılmıyor ki aynı olay iki kez duyurulmasın.
1704  ov.querySelector('#giKaybolma')?.addEventListener('change', async (e) => {
1705    const sn = parseInt(e.target.value, 10) || 0;
1706    const onceki = Number(kaybolma.seconds || 0);
1707    e.target.disabled = true;
1708    try {
1709      const r = await api.put(`/api/conversations/${convInfo.id}/disappearing`, { seconds: sn });
1710      kaybolma = r;
1711      _kaybolmaIpucu(r.seconds);
1712      toast(sn > 0 ? t('chat.disappear.set_toast', { label: _sureEtiketi(sn) })
1713                   : t('chat.disappear.off_toast'), 'success');
1714    } catch (err) {
1715      e.target.value = String(onceki);       // reddedildi → seçim geri alınsın
1716      toast(err.message, 'error');
1717    } finally { e.target.disabled = false; }
1718  });
1719  ov.querySelector('#giInviteAgent')?.addEventListener('click', () => _openAgentInviteModal(convInfo));
1720  ov.querySelector('#giLeave')?.addEventListener('click', async () => {
1721    if (!confirm(t('chat.info.leave_confirm'))) return;
1722    try {
1723      await api.post(`/api/conversations/${convInfo.id}/leave`, {});
1724      ov.remove();
1725      toast(t('chat.info.left_group'), 'success');
1726      window.morfozRefreshChatList && window.morfozRefreshChatList();
1727    } catch (e) { toast(e.message, 'error'); }
1728  });
1729}
1730
1731async function _openAgentInviteModal(convInfo) {
1732  let rows = [];
1733  try { rows = await api.get('/api/assistants/accessible'); } catch {}
1734  const candidates = rows.filter(r => ['owner', 'rep', 'member'].includes(r.my_role) || r.visibility === 'public');
1735  // SİSTEM NOTÇUSU AYRI BİR UÇTAN GELİYOR: /api/assistants/accessible onu
1736  // döndürmüyor (kimsenin "erişebildiği asistanı" değil, herkesin ortak
1737  // yardımcısı). Birleştirmeseydik listede hiç görünmez ve toplantı notu
1738  // özelliği panelde ulaşılamaz kalırdı.
1739  let sistemler = [];
1740  try {
1741    const y = await api.get('/api/meetings/helper-assistants');
1742    sistemler = ((y && y.assistants) || [])
1743      .filter(a => a.is_system && !candidates.some(c => Number(c.id) === Number(a.id)));
1744  } catch { /* uç yoksa liste eskisi gibi çalışır */ }
1745  const liste = [
1746    ...sistemler.map(a => ({ ...a, _sistem: true })),
1747    ...candidates,
1748  ];
1749  document.getElementById('agentInviteModal')?.remove();
1750  const ov = document.createElement('div');
1751  ov.id = 'agentInviteModal';
1752  ov.style.cssText = 'position:fixed;inset:0;z-index:2300;background:rgba(20,10,40,.45);display:flex;align-items:center;justify-content:center;padding:16px;';
1753  const list = liste.map(a => `
1754    <button data-invite="${a.id}" data-notetaker="${a.default_notetaker ? '1' : '0'}" style="display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:1px solid ${a._sistem ? 'var(--brand,#6B2387)' : 'var(--border,rgba(58,28,89,.1))'};background:var(--surface-2,rgba(0,0,0,.02));border-radius:10px;padding:8px 10px;margin-bottom:6px;cursor:pointer;font:inherit;color:inherit;">
1755      <div class="avatar tone-${a.avatar_tone || 2}" style="width:30px;height:30px;font-size:12px;">${a._sistem ? '📝' : escapeHtml((a.name || '?').slice(0, 1).toUpperCase())}</div>
1756      <div style="flex:1;min-width:0;">
1757        <div style="font-size:13px;font-weight:600;">${escapeHtml(a.name)} ${a.klon_kind === 'individual' ? '🧬' : ''}${a._sistem ? `<span class="gi-tag" style="color:var(--brand,#6B2387);">${escapeHtml(t('meet.system_notetaker'))}</span>` : ''}</div>
1758        <div style="font-size:10.5px;color:var(--text-tertiary,#A89DB5);">${a._sistem ? escapeHtml(a.description || t('meet.system_notetaker_desc')) : (a.my_role === 'owner' ? escapeHtml(t('chat.info.you_own')) : escapeHtml(a.my_role || ''))}</div>
1759      </div>
1760    </button>`).join('');
1761  ov.innerHTML = `
1762    <div style="background:var(--surface-1,#fff);color:var(--text-primary,#1F1430);border-radius:16px;max-width:400px;width:100%;max-height:80vh;overflow:auto;padding:18px;box-shadow:0 18px 50px rgba(20,10,40,.35);">
1763      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
1764        <div style="font-size:15px;font-weight:700;">🤖 ${escapeHtml(t('chat.info.invite_agent'))}</div>
1765        <button id="aiClose" style="border:none;background:transparent;font-size:20px;cursor:pointer;color:inherit;">×</button>
1766      </div>
1767      <div style="font-size:11.5px;color:var(--text-tertiary,#8A7E9B);margin-bottom:10px;line-height:1.5;">
1768        ${escapeHtml(t('chat.info.invite_note'))}
1769      </div>
1770      <label style="display:flex;align-items:center;gap:8px;font-size:12px;margin-bottom:4px;">
1771        <input type="checkbox" id="aiDigest" checked> ${escapeHtml(t('chat.info.opt_digest'))}
1772      </label>
1773      <label style="display:flex;align-items:center;gap:8px;font-size:12px;margin-bottom:4px;">
1774        <input type="checkbox" id="aiAlert" checked> ${escapeHtml(t('chat.info.opt_alert'))}
1775      </label>
1776      <!-- VARSAYILAN KAPALI: bir görüşmenin dökümünü almak açıkça istenmesi
1777           gereken bir şey (sunucu da notes_enabled=False varsayıyor).
1778           NOT: bu yorum bir ŞABLON DİZESİNİN içinde — buraya ters tırnak
1779           yazılırsa dize orada kapanır ve panel hiç açılmaz. -->
1780      <label style="display:flex;align-items:center;gap:8px;font-size:12px;margin-bottom:10px;">
1781        <input type="checkbox" id="aiNotes"> 📝 ${escapeHtml(t('meet.invite_notes'))}
1782      </label>
1783      ${list || `<div style="font-size:12px;color:#A89DB5;">${escapeHtml(t('chat.info.no_invitable'))}</div>`}
1784    </div>
1785  `;
1786  document.body.appendChild(ov);
1787  ov.addEventListener('click', (e) => { if (e.target === ov) ov.remove(); });
1788  ov.querySelector('#aiClose').addEventListener('click', () => ov.remove());
1789  ov.querySelectorAll('[data-invite]').forEach(b => b.addEventListener('click', async () => {
1790    try {
1791      await api.post(`/api/conversations/${convInfo.id}/agents`, {
1792        assistant_id: Number(b.dataset.invite),
1793        reply_mode: 'smart',
1794        digest_enabled: ov.querySelector('#aiDigest').checked,
1795        digest_interval_min: 1440,
1796        alert_enabled: ov.querySelector('#aiAlert').checked,
1797        // NOTÇUYU SEÇMEK NOT TUTMAYI İSTEMEKTİR: kutu işaretlenmemiş olsa da
1798        // sistem notçusunu davet eden kişi tam olarak bunu istiyor. Aksi
1799        // hâlde notçu odaya girer ve hiçbir şey yazmazdı.
1800        notes_enabled: ov.querySelector('#aiNotes').checked || b.dataset.notetaker === '1',
1801      });
1802      ov.remove();
1803      toast(t('chat.info.agent_invited'), 'success');
1804      // Arkadaki grup bilgisi modalını da tazele — üye eklemedeki aynı sorun:
1805      // davet edilen asistan listeye ancak modal kapatılıp açılınca düşüyordu.
1806      if (document.getElementById('groupInfoModal')) {
1807        document.getElementById('groupInfoModal').remove();
1808        _openGroupInfoModal(convInfo);
1809      }
1810    } catch (e) { toast(e.message, 'error'); }
1811  }));
1812}
1813
1814
1815/**
1816 * Quick Actions strip — pills mounted just above the chat composer. Pulls
1817 * /api/quick-actions/resolve for (assistant, viewerType), groups by pool,
1818 * and supports in-place submenu navigation via a breadcrumb header so
1819 * nested menus stay touch-friendly without a floating popover.
1820 *
1821 * Click flow for a LEAF action:
1822 *   1. POST /api/quick-actions/actions/{id}/trigger → { user_message, canned_reply }
1823 *   2. Fill .composer-input textarea with user_message
1824 *   3. Click #sendBtn so the existing chat send pipeline takes over
1825 *   4. If canned_reply is set, paint it as the assistant's reply locally
1826 *      (bypasses the LLM round-trip; the message still gets persisted on
1827 *      the next conversation reload from the chat backend)
1828 */
1829const _qaStripCache = new Map();    // key → resolved pools
1830const _qaNavStack   = new Map();    // assistantId → [ {label, children}, ... ]
1831const _qaIdleTimers = new Map();    // conversationId → timeout handle
1832const _qaFiredOnStart = new Set();  // `${convId}:${poolId}` so on_start fires once per conversation
1833
1834async function _renderQuickActionsStrip(assistantId) {
1835  if (!api.token) return;
1836  _injectQaStripStyles();
1837  // Mount target: chat composer wrap. Insert strip BEFORE it so pills sit
1838  // between the message scroller and the textarea.
1839  const composerWrap = document.querySelector('#chatView .composer-wrap')
1840                    || document.querySelector('.composer-wrap');
1841  if (!composerWrap) return;
1842  let strip = document.getElementById('chatQuickActions');
1843  if (!strip) {
1844    strip = document.createElement('div');
1845    strip.id = 'chatQuickActions';
1846    strip.className = 'qa-strip';
1847    composerWrap.parentNode.insertBefore(strip, composerWrap);
1848  }
1849  // Reset transient navigation when the user switches assistant
1850  if (strip.dataset.assistantId !== String(assistantId)) {
1851    _qaNavStack.delete(strip.dataset.assistantId);
1852    strip.dataset.assistantId = String(assistantId);
1853  }
1854
1855  const viewerType = window.morfozActiveMode === 'business' ? 'business' : 'customer';
1856  const cacheKey = `${assistantId}:${viewerType}`;
1857  const cached = _qaStripCache.get(cacheKey);
1858  // 60s TTL so newly attached / edited menus show up without a page reload
1859  let pools = (cached && (Date.now() - cached.at < 60_000)) ? cached.pools : null;
1860  if (!pools) {
1861    try {
1862      pools = await api.get(`/api/quick-actions/resolve?assistant_id=${assistantId}&viewer_user_type=${viewerType}`);
1863      _qaStripCache.set(cacheKey, { pools, at: Date.now() });
1864    } catch { strip.style.display = 'none'; return; }
1865  }
1866  // No menus configured for this assistant — hide the strip entirely
1867  if (!pools || !pools.length) { strip.style.display = 'none'; return; }
1868  strip.style.display = '';
1869
1870  _paintQaPills(strip, assistantId, pools);
1871
1872  // Auto-fire on_start actions + arm the idle timer for the current
1873  // conversation. Both pieces depend on state.conversationId being known
1874  // — chat-flow sets it just before this function is invoked.
1875  _autoFireOnStart(pools);
1876  _armIdleTimer(pools);
1877}
1878
1879/**
1880 * Fire trigger_on_start actions exactly once per (conversation, pool).
1881 * Each is rendered as an assistant bubble (with optional canned response).
1882 * The pool can also carry a `start_greeting` plain text that's posted first.
1883 */
1884function _autoFireOnStart(pools) {
1885  const convId = state.conversationId;
1886  if (!convId) return;
1887  pools.forEach(pool => {
1888    const key = `${convId}:${pool.pool_id}`;
1889    if (_qaFiredOnStart.has(key)) return;
1890    const greeting = (pool.start_greeting || '').trim();
1891    const onStart = pool.on_start || [];
1892    if (!greeting && !onStart.length) return;
1893    _qaFiredOnStart.add(key);
1894    // Stagger so the chat doesn't render five bubbles at once
1895    let delay = 350;
1896    if (greeting) {
1897      setTimeout(() => _appendCannedAssistantReply(greeting), delay);
1898      delay += 700;
1899    }
1900    onStart.forEach(act => {
1901      // The fired action's "label" + first canned response form the bubble.
1902      // If only one is set, use that; if both, show label as a title.
1903      const body = act.responses && act.responses.length
1904        ? act.responses[0]
1905        : (act.user_message || act.label || act.name || '');
1906      const composed = (act.label && act.responses && act.responses.length)
1907        ? `${act.label}\n\n${body}`
1908        : body;
1909      const ms = delay;
1910      setTimeout(() => _appendCannedAssistantReply(composed), ms);
1911      delay += 700;
1912    });
1913  });
1914}
1915
1916/**
1917 * Arm the idle-timer. After `idle_timeout_sec` of zero user typing/sending,
1918 * we fire each pool's `on_idle` action(s) as assistant nudges. The timer
1919 * is reset on any user input (see _hookIdleResets).
1920 */
1921function _armIdleTimer(pools) {
1922  const convId = state.conversationId;
1923  if (!convId) return;
1924  const existing = _qaIdleTimers.get(convId);
1925  if (existing) clearTimeout(existing);
1926  // Pick the SHORTEST configured timeout among pools that have on_idle
1927  // actions — if multiple pools nudge, the most aggressive wins; the others
1928  // still fire on subsequent timeout rounds.
1929  const candidates = pools
1930    .filter(p => p.idle_timeout_sec && (p.on_idle || []).length)
1931    .map(p => p.idle_timeout_sec);
1932  if (!candidates.length) return;
1933  const seconds = Math.min(...candidates);
1934  const handle = setTimeout(() => _onIdleTimeout(pools), seconds * 1000);
1935  _qaIdleTimers.set(convId, handle);
1936  // One-time: ensure user input resets the timer
1937  _hookIdleResets();
1938}
1939
1940function _onIdleTimeout(pools) {
1941  const convId = state.conversationId;
1942  if (!convId) return;
1943  // Don't nudge if the chat view isn't visible — user has navigated away
1944  const chatView = document.getElementById('chatView');
1945  if (chatView && chatView.offsetParent === null) {
1946    _armIdleTimer(pools);
1947    return;
1948  }
1949  pools.forEach(pool => {
1950    (pool.on_idle || []).forEach((act, i) => {
1951      const body = act.responses && act.responses.length
1952        ? act.responses[0]
1953        : (act.user_message || act.label || '');
1954      if (!body) return;
1955      setTimeout(() => _appendCannedAssistantReply(body, { meta: t('chat.qa.reminder') }), i * 600);
1956    });
1957  });
1958  // Re-arm so subsequent idle periods nudge again
1959  _armIdleTimer(pools);
1960}
1961
1962let _idleResetsHooked = false;
1963function _hookIdleResets() {
1964  if (_idleResetsHooked) return;
1965  _idleResetsHooked = true;
1966  const reset = () => {
1967    const convId = state.conversationId;
1968    if (!convId) return;
1969    const cached = _qaStripCache.get(`${convId}`) || null;  // unused; we rely on per-conv handle map
1970    const handle = _qaIdleTimers.get(convId);
1971    if (handle) clearTimeout(handle);
1972    // Re-fetch pools from cache to re-arm — chat-flow keeps the most recent
1973    // pool list keyed by assistant + viewer type. We re-resolve by walking
1974    // the cache for the active assistant.
1975    const aid = state.activeAssistantId;
1976    const viewerType = window.morfozActiveMode === 'business' ? 'business' : 'customer';
1977    const entry = _qaStripCache.get(`${aid}:${viewerType}`);
1978    if (entry && entry.pools) _armIdleTimer(entry.pools);
1979  };
1980  // Reset on textarea typing + on send
1981  document.addEventListener('input', (e) => {
1982    if (e.target && e.target.classList && e.target.classList.contains('composer-input')) reset();
1983  });
1984  document.addEventListener('click', (e) => {
1985    const btn = e.target && e.target.closest && e.target.closest('#sendBtn');
1986    if (btn) reset();
1987  });
1988}
1989
1990/** Paint either the top-level pool buttons OR the current breadcrumb level. */
1991function _paintQaPills(strip, assistantId, pools) {
1992  const stack = _qaNavStack.get(String(assistantId)) || [];
1993  strip.innerHTML = '';
1994  // Breadcrumb header when navigated into a submenu
1995  if (stack.length) {
1996    const back = document.createElement('button');
1997    back.type = 'button';
1998    back.className = 'qa-back';
1999    back.innerHTML = `<span>←</span> ${escapeQa(stack[stack.length - 1].label)}`;
2000    back.addEventListener('click', () => {
2001      stack.pop();
2002      if (stack.length === 0) _qaNavStack.delete(String(assistantId));
2003      else _qaNavStack.set(String(assistantId), stack);
2004      _paintQaPills(strip, assistantId, pools);
2005    });
2006    strip.appendChild(back);
2007  }
2008  // Decide what to paint at this level
2009  let nodes;
2010  if (stack.length) {
2011    nodes = stack[stack.length - 1].children || [];
2012  } else {
2013    // Flatten root-level children from every applicable pool
2014    nodes = [];
2015    pools.forEach(p => (p.root_children || []).forEach(n => nodes.push(n)));
2016  }
2017  if (!nodes.length) {
2018    const empty = document.createElement('span');
2019    empty.className = 'qa-pill-empty';
2020    empty.textContent = t('chat.qa.empty');
2021    strip.appendChild(empty);
2022    return;
2023  }
2024  nodes.forEach(node => {
2025    const btn = document.createElement('button');
2026    btn.type = 'button';
2027    const hasChildren = node.children && node.children.length;
2028    const kind = node.click_action || 'send';
2029    btn.className = 'qa-pill' + (hasChildren ? ' qa-pill-parent' : '');
2030    btn.dataset.kind = kind;
2031    // Small chip showing what happens on click — only when it's not the
2032    // boring default "send" or a submenu opener (those don't need it).
2033    const kindHint = (!hasChildren && kind !== 'send') ? _qaKindHint(kind) : '';
2034    btn.innerHTML = `${node.icon ? `<span class="qa-pill-icon">${escapeQa(node.icon)}</span>` : ''}<span>${escapeQa(node.label || node.name || '')}</span>${kindHint}${hasChildren ? '<span class="qa-pill-arrow">›</span>' : ''}`;
2035    btn.addEventListener('click', () => _onQaPillClick(strip, assistantId, pools, node));
2036    strip.appendChild(btn);
2037  });
2038}
2039
2040function _qaKindHint(kind) {
2041  const labels = {
2042    tool: `⚡ ${t('chat.qa.action')}`,
2043    view: `🪟 ${t('chat.qa.kind_view')}`,
2044    url:  `↗ ${t('chat.qa.kind_url')}`,
2045    form: `📝 ${t('chat.qa.kind_form')}`,
2046  };
2047  const lbl = labels[kind];
2048  return lbl ? `<span class="qa-pill-kind">${escapeQa(lbl)}</span>` : '';
2049}
2050
2051/** Click handler — submenu navigation or leaf-action dispatch. */
2052async function _onQaPillClick(strip, assistantId, pools, node) {
2053  if (node.children && node.children.length) {
2054    const stack = _qaNavStack.get(String(assistantId)) || [];
2055    stack.push({ label: node.label || node.name || '', children: node.children });
2056    _qaNavStack.set(String(assistantId), stack);
2057    _paintQaPills(strip, assistantId, pools);
2058    return;
2059  }
2060  // Dispatch by click_action — the node's payload from /resolve carries it.
2061  const kind = node.click_action || 'send';
2062  try {
2063    if (kind === 'url') {
2064      // External URL — open in new tab (or use tel:/mailto: protocols inline)
2065      if (node.action_target) window.open(node.action_target, '_blank', 'noopener');
2066    } else if (kind === 'view') {
2067      // Switch the panel to a registered view (e.g. 'wallet', 'modules', 'orders')
2068      const railBtn = document.querySelector(`.rail-btn[data-nav="${node.action_target}"]`);
2069      if (railBtn) {
2070        railBtn.click();
2071      } else {
2072        toast(t('chat.qa.view_not_found', { target: node.action_target }), 'error');
2073      }
2074    } else if (kind === 'tool') {
2075      // Backend tool — POST /invoke. Forward the chat context (assistant +
2076      // conversation) so tools like restaurant.call_waiter can attribute the
2077      // call to the correct workspace owner.
2078      const ctxPayload = {
2079        assistant_id:    state.activeAssistantId,
2080        conversation_id: state.conversationId,
2081      };
2082      const res = await api.post(`/api/quick-actions/actions/${node.id}/invoke`,
2083                                 { payload: ctxPayload });
2084      if (res && res.message) {
2085        _appendCannedAssistantReply(res.message, { meta: t('chat.qa.action') });
2086      }
2087      if (res && Array.isArray(res.side_effects) && res.side_effects.length) {
2088        // Render side-effects as a small system-style line beneath the bubble
2089        _appendSideEffect(res.side_effects.join(' · '));
2090      }
2091    } else if (kind === 'form') {
2092      // Future: open a form modal. For now, fall through to 'send' behavior.
2093      const trig = await api.post(`/api/quick-actions/actions/${node.id}/trigger`, {});
2094      await _executeQuickAction(trig);
2095    } else {
2096      // Default: send the action's text as a user message (current behavior)
2097      let trig;
2098      try {
2099        trig = await api.post(`/api/quick-actions/actions/${node.id}/trigger`, {});
2100      } catch {
2101        trig = { user_message: node.send_text || node.label || node.name, canned_reply: null };
2102      }
2103      await _executeQuickAction(trig);
2104    }
2105  } catch (e) {
2106    toast(e.message || t('chat.qa.action_failed'), 'error');
2107  }
2108  // Reset breadcrumb after a click so the next interaction starts at root
2109  _qaNavStack.delete(String(assistantId));
2110  _paintQaPills(strip, assistantId, pools);
2111}
2112
2113function _appendSideEffect(text) {
2114  const inner = document.querySelector('#chatView .chat-inner');
2115  if (!inner) return;
2116  const div = document.createElement('div');
2117  div.className = 'qa-side-effect';
2118  div.textContent = '↳ ' + text;
2119  inner.appendChild(div);
2120  inner.scrollTop = inner.scrollHeight;
2121}
2122
2123/** Push the resolved action into the existing chat send pipeline. */
2124async function _executeQuickAction(trig) {
2125  const input = document.querySelector('#chatView .composer-input')
2126             || document.querySelector('.composer-input');
2127  const sendBtn = document.querySelector('#chatView #sendBtn')
2128              || document.getElementById('sendBtn');
2129  if (!input || !sendBtn) return;
2130  input.value = trig.user_message || '';
2131  // Fire input event so the existing send-button enabled/disabled logic and
2132  // autosize hooks pick up the new value.
2133  input.dispatchEvent(new Event('input', { bubbles: true }));
2134  // The send button stays disabled when empty — make sure we have content.
2135  if (!input.value.trim()) return;
2136  sendBtn.disabled = false;
2137  sendBtn.click();
2138  // If the action has a canned reply, paint it as the assistant's bubble
2139  // shortly after so the user sees the scripted answer without waiting on
2140  // the LLM. The chat backend persists future-turn context regardless.
2141  if (trig.canned_reply) {
2142    setTimeout(() => _appendCannedAssistantReply(trig.canned_reply), 400);
2143  }
2144}
2145
2146function _appendCannedAssistantReply(text, opts = {}) {
2147  const inner = document.querySelector('#chatView .chat-inner');
2148  if (!inner) return;
2149  const meta = opts.meta || t('chat.qa.quick_action');
2150  const wrap = document.createElement('div');
2151  wrap.className = 'msg-row assistant qa-canned';
2152  wrap.innerHTML = `
2153    <div class="msg-avatar"></div>
2154    <div class="bubble assistant">
2155      <div class="bubble-text">${escapeQa(text)}</div>
2156      <div class="bubble-meta">${escapeQa(t('chat.now'))} · ${escapeQa(meta)}</div>
2157    </div>`;
2158  inner.appendChild(wrap);
2159  inner.scrollTop = inner.scrollHeight;
2160}
2161
2162function escapeQa(s) {
2163  return String(s ?? '').replace(/[&<>"']/g, c => ({
2164    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
2165  }[c]));
2166}
2167
2168function _injectQaStripStyles() {
2169  if (document.getElementById('qa-strip-styles')) return;
2170  const s = document.createElement('style');
2171  s.id = 'qa-strip-styles';
2172  s.textContent = `
2173    .qa-strip {
2174      display:flex; flex-wrap:wrap; gap:6px;
2175      padding:8px 14px 6px;
2176      max-height: 96px; overflow-y:auto;
2177      border-top:1px solid var(--border, rgba(58,28,89,.06));
2178      background: linear-gradient(180deg, transparent 0%, rgba(248,240,251,.4) 100%);
2179    }
2180    .qa-back {
2181      display:inline-flex; align-items:center; gap:4px;
2182      background: rgba(31,20,48,.85); color:#fff; border:0;
2183      border-radius:100px; padding:5px 12px;
2184      font:inherit; font-size:11.5px; font-weight:600;
2185      cursor:pointer;
2186    }
2187    .qa-back:hover { background:#1F1430; }
2188    .qa-pill {
2189      display:inline-flex; align-items:center; gap:5px;
2190      background: var(--soft-brand-bg); color: var(--soft-brand-fg);
2191      border:1px solid rgba(107,35,135,.18);
2192      border-radius:100px; padding:6px 12px;
2193      font:inherit; font-size:12px; font-weight:500;
2194      cursor:pointer;
2195      transition: background .12s, transform .08s;
2196    }
2197    .qa-pill:hover { background:var(--surface-3, #EAD5F4); }
2198    .qa-pill:active { transform: scale(0.96); }
2199    .qa-pill-parent { background:var(--surface, #fff); border-color: rgba(107,35,135,.3); }
2200    .qa-pill-parent:hover { background:var(--surface-2, #F8F0FB); }
2201    .qa-pill-icon { font-size:13px; line-height:1; }
2202    .qa-pill-arrow { color:#A89DB5; font-size:14px; line-height:1; margin-left:2px; }
2203    .qa-pill-kind {
2204      font-size:10px; padding:2px 6px; border-radius:8px; line-height:1;
2205      background:rgba(31,20,48,.08); color:var(--text-secondary, #5C4B6E); margin-left:2px;
2206    }
2207    .qa-pill[data-kind="tool"] .qa-pill-kind { background: var(--soft-warning-bg); color: var(--soft-warning-fg); }
2208    .qa-pill[data-kind="view"] .qa-pill-kind { background: var(--soft-info-bg); color: var(--soft-info-fg); }
2209    .qa-pill[data-kind="url"]  .qa-pill-kind { background: var(--soft-success-bg); color: var(--soft-success-fg); }
2210    .qa-side-effect {
2211      align-self:flex-start; color:var(--text-tertiary,#A89DB5);
2212      font-size:11px; font-style:italic; padding:2px 14px 6px 60px;
2213    }
2214    .qa-pill-empty {
2215      color:var(--text-tertiary, #A89DB5); font-size:12px; padding:6px 4px;
2216    }
2217    body[data-theme="dark"] .qa-strip {
2218      background: linear-gradient(180deg, transparent 0%, rgba(43,30,60,.4) 100%);
2219    }
2220    body[data-theme="dark"] .qa-pill {
2221      background: rgba(107,35,135,.18); color:#E9D5F0;
2222    }
2223    body[data-theme="dark"] .qa-pill-parent { background: rgba(255,255,255,.04); }
2224  `;
2225  document.head.appendChild(s);
2226}
2227
2228
2229// "Alıntıya git" araması: tur başına çekilen eski mesaj ve tur tavanı.
2230// PAGE_SIZE (20) ile geriye yürümek uzak bir hedef için onlarca gidiş-dönüş
2231// demekti. Tavan dolunca arama BAŞARISIZ SAYILMIYOR: kullanıcıya "tekrar
2232// dokun" deniyor ve ikinci dokunuş kaldığı yerden 720 mesaj daha geriye
2233// gidiyor (yüklenen sayfalar DOM'da kalıyor, imleç geriye kaymış oluyor).
2234const ARAMA_SAYFASI = 60;
2235const ARAMA_TUR_TAVANI = 12;
2236
2237// Uçuştaki eski-sayfa isteği. İki çağıran var (yukarı kaydırma dinleyicisi ve
2238// alıntı araması) ve İKİNCİSİNE AYNI SÖZÜ döndürmek zorundayız: eskiden
2239// `state.loadingOlder` görülünce sessizce `return` ediliyordu, yani döngü
2240// içinden çağıran taraf hiç beklemeden tur atardı (sıkı döngü).
2241let _eskiSayfaUcusu = null;
2242
2243/** Load the next older page when the user scrolls near the top.
2244 *  @returns {Promise<number>} eklenen satır sayısı (0 = hiçbir şey eklenmedi) */
2245function loadOlderPage(limit = PAGE_SIZE) {
2246  if (!state.hasMoreOlder || !state.conversationId || state.oldestLoadedId == null) {
2247    return Promise.resolve(0);
2248  }
2249  if (_eskiSayfaUcusu) return _eskiSayfaUcusu;
2250  state.loadingOlder = true;
2251  _eskiSayfaUcusu = _eskiSayfayiCek(limit).finally(() => {
2252    _eskiSayfaUcusu = null;
2253    state.loadingOlder = false;
2254  });
2255  return _eskiSayfaUcusu;
2256}
2257
2258async function _eskiSayfayiCek(limit) {
2259  // Yanıt dönene kadar kullanıcı BAŞKA bir sohbete geçmiş olabilir. Kimliği
2260  // şimdi sabitliyoruz; aşağıda yanıt gelince hâlâ aynı sohbette miyiz diye
2261  // bakılıyor. Bakmasaydık eski sohbetin mesajları yeni sohbetin altına
2262  // ekleniyordu (sentinel de kopmuş oluyor → insertBefore(frag, null) = dibe).
2263  const acilanConvId = state.conversationId;
2264  const chatInner = document.querySelector('#chatView .chat-inner');
2265  // initChatFlow ile AYNI sözleşme — gerekçe orada. Burada da ölçüme
2266  // güvenseydik kısa (taşmayan) sohbette konum koruması yanlış elemana yazardı.
2267  const scrollEl = chatInner.closest('.chat')
2268    || findScrollableAncestor(chatInner) || chatInner;
2269  const loader = document.getElementById('olderLoader');
2270  if (loader) loader.style.display = '';
2271
2272  // Remember the visual position so prepended content doesn't shift the viewport
2273  const oldScrollHeight = scrollEl.scrollHeight;
2274  const oldScrollTop = scrollEl.scrollTop;
2275
2276  try {
2277    const url = `/api/conversations/${acilanConvId}/messages?before_id=${state.oldestLoadedId}&limit=${limit}`;
2278    const older = await api.get(url);
2279    if (state.conversationId !== acilanConvId) return 0;  // sohbet değişti, yanıtı at
2280    // `await` sonrası TAZE referans: sohbet yeniden çizilmişse eski düğüm koptu.
2281    const sentinel = document.getElementById('olderLoader');
2282    if (sentinel) sentinel.style.display = 'none';
2283    if (!older.length) {
2284      state.hasMoreOlder = false;
2285      return 0;
2286    }
2287    // ŞİFRELİ SATIRLAR ÖNCE ÇÖZÜLÜR — ortak kapı (bkz. satirlariCoz).
2288    // Bu yol atlanınca yukarı kaydıran kullanıcı eski mesajları HAM base64
2289    // zarf olarak görüyordu; ilk sayfa doğru, geçmiş bozuk görünüyordu.
2290    await satirlariCoz(older);
2291    // Build all older bubbles into a fragment, in chronological order
2292    const frag = document.createDocumentFragment();
2293    older.forEach(m => renderHistoricalMessage(frag, m));
2294    // Insert AFTER the loader sentinel (so the loader stays at top)
2295    const after = sentinel ? sentinel.nextSibling : chatInner.firstChild;
2296    // Gözlemciyi sustur: yukarı eklenen satırlar "yeni mesaj geldi" değil.
2297    // Bayrak olmasaydı gözlemci scrollToBottom tetikler, aşağıdaki özenli
2298    // konum onarımının üstüne yazar ve kullanıcı yüklediği sayfayı hiç göremezdi.
2299    state.konumOnariliyor = true;
2300    chatInner.insertBefore(frag, after);
2301    // Fragman kendi içinde tarandı ama SON kartı ancak şimdi, altındaki yeni
2302    // cevaplarla aynı kaba girince eski sayılabiliyor (bkz. _onrBayatlariIsaretle).
2303    _onrBayatlariIsaretle(chatInner);
2304    // Update pagination cursors
2305    state.oldestLoadedId = Math.min(state.oldestLoadedId ?? Infinity, ...older.map(m => m.id));
2306    state.hasMoreOlder = older.length >= limit;
2307    // Preserve visual scroll position — content grew at the top by Δ
2308    const delta = scrollEl.scrollHeight - oldScrollHeight;
2309    scrollEl.scrollTop = oldScrollTop + delta;
2310    requestAnimationFrame(() => { state.konumOnariliyor = false; });
2311    return older.length;
2312  } catch (e) {
2313    if (loader) loader.style.display = 'none';
2314    console.warn('[chat-flow] older page load failed', e);
2315    return 0;
2316  } finally {
2317    // Bayrak TAKILI KALMAMALI. Yukarıdaki rAF normal akışta sıfırlıyor, ama
2318    // araya bir istisna girerse bayrak sonsuza kadar true kalır ve gözlemci
2319    // sessizce ölür (yeni mesajlar bir daha hiç takip edilmez). İki kez
2320    // sıfırlamak zararsız; hiç sıfırlamamak sohbeti bozar.
2321    requestAnimationFrame(() => { state.konumOnariliyor = false; });
2322  }
2323}
2324
2325/**
2326 * Bir mesaj EKRANA GELENE KADAR geçmişi yükle. "Alıntıya git" bunun üstünde
2327 * duruyor (chat-actions.js → jumpTo).
2328 *
2329 * ⛔ NEDEN VAR: ilk sayfa 20 mesaj. Alıntı bloğuna, sabitlenmiş mesaj bandına
2330 * ya da yıldızlı listeye dokunmak, hedef o 20 satırın DIŞINDAYSA "yukarı
2331 * kaydırıp yükleyin" uyarısıyla bitiyordu — yani kullanıcıya "kendin ara"
2332 * deniyordu. Sabitlenmiş ve yıldızlı mesajlar tanımı gereği eskidir, o iki yol
2333 * pratikte hiç çalışmıyordu; DM'de de sohbeti yeni açan biri iki gün önceki
2334 * bir alıntıya dokunduğunda aynı duvara çarpıyordu.
2335 *
2336 * Sayfalama BURADA yaşıyor: imleç (`oldestLoadedId`), kaydırma konumunun
2337 * onarımı ve şifreli satırların çözülmesi bu dosyanın sözleşmesi. chat-actions
2338 * yalnız sonucu alıp satırı vurguluyor.
2339 *
2340 * @returns {Promise<'var'|'iptal'|'yok'|'uzak'|'ileride'|'hata'>}
2341 *   var     → satır artık DOM'da
2342 *   iptal   → kullanıcı arama sürerken başka sohbete geçti (sessiz çıkış)
2343 *   yok     → geçmişin başına gelindi ya da imleç hedefi geçti: mesaj bu
2344 *             kullanıcıya görünmüyor (silinmiş / gizlenmiş / engelli gönderen)
2345 *   uzak    → tur tavanı doldu, hedef daha geride (tekrar denenebilir)
2346 *   ileride → hedef, yüklü en yeni mesajdan da yeni; geçmişe inmek çözmez
2347 *   hata    → istek başarısız
2348 */
2349async function mesajaKadarYukle(hedefId) {
2350  const hedef = Number(hedefId);
2351  const chatInner = document.querySelector('#chatView .chat-inner');
2352  if (!chatInner || !Number.isFinite(hedef)) return 'yok';
2353  const bul = () => chatInner.querySelector(
2354    `.msg-row[data-msg-id="${CSS.escape(String(hedefId))}"]`);
2355  if (bul()) return 'var';
2356  // Hedef, yüklü EN YENİ mesajdan da yeniyse geçmişe inmek onu asla bulmaz.
2357  // (Yıldızlı listeden başka bir sohbete atlarken buraya sohbet daha çizilmeden
2358  // gelinebiliyor.) 12 tur boşuna sayfalamak yerine hemen söylüyoruz.
2359  // Hiç satır çizilmemişse sohbet HENÜZ YÜKLENİYOR demektir (yıldızlı listeden
2360  // başka sohbete atlarken oluyor). Geçmişe inmeye çalışmak burada "geçmiş
2361  // yüklenemedi" hatası üretirdi; oysa doğru cevap "mesaj henüz yüklenmedi".
2362  const yuklu = [...chatInner.querySelectorAll('.msg-row[data-msg-id]')]
2363    .map(el => parseInt(el.dataset.msgId, 10)).filter(Number.isFinite);
2364  if (!yuklu.length || hedef > Math.max(...yuklu)) return 'ileride';
2365
2366  const acilanConvId = state.conversationId;
2367  // ARAMA SESSİZ KALMAZ (panel kuralı). Üst şerit her isteği zaten sürüyor ama
2368  // kullanıcının gözü sohbette: yukarıdaki yükleme etiketini bu tur boyunca
2369  // "alıntı aranıyor" diye konuşturup sonunda eski metnine bırakıyoruz.
2370  const etiket = document.getElementById('olderLoader');
2371  const eskiMetin = etiket ? etiket.textContent : null;
2372  if (etiket) etiket.textContent = t('chat.act.quote_searching');
2373  let bosTur = 0;
2374  try {
2375    for (let tur = 0; tur < ARAMA_TUR_TAVANI; tur++) {
2376      if (state.conversationId !== acilanConvId) return 'iptal';
2377      if (!state.hasMoreOlder) return 'yok';                     // geçmişin başı
2378      const eklendi = await loadOlderPage(ARAMA_SAYFASI);
2379      // Sohbet BEKLERKEN de değişmiş olabilir. Bunu ölçmezsek `eklendi` 0
2380      // döner (istek başka sohbete ait sayılıp atılır) ve kullanıcı, artık
2381      // bakmadığı bir sohbet için "geçmiş yüklenemedi" hatası görürdü.
2382      if (state.conversationId !== acilanConvId) return 'iptal';
2383      if (bul()) return 'var';
2384      // BOŞ TUR HEMEN HATA DEĞİL. İstek uçuştaki bir başka sayfayı devralmış
2385      // olabilir (bkz. `_eskiSayfaUcusu`): o sayfa önceki sohbete aitse 0
2386      // döner, oysa BU sohbette daha çekilecek geçmiş vardır. Bir kez daha
2387      // deniyoruz; üst üste iki boş tur gerçekten hatadır.
2388      if (!eklendi) {
2389        if (!state.hasMoreOlder) return 'yok';
2390        if (++bosTur >= 2) return 'hata';
2391        continue;
2392      }
2393      bosTur = 0;
2394      // İmleç hedefin GERİSİNE geçtiği hâlde satır yoksa mesaj bu kullanıcıya
2395      // görünmüyor demektir (silinmiş, gizlenmiş ya da engellenen birinden).
2396      // Aramayı sürdürmek sohbetin başına kadar boşuna sayfalamak olurdu.
2397      if (state.oldestLoadedId != null && state.oldestLoadedId <= hedef) return 'yok';
2398    }
2399  } finally {
2400    if (etiket && eskiMetin != null) etiket.textContent = eskiMetin;
2401  }
2402  return 'uzak';
2403}
2404
2405function hookOlderScroll(scrollEl) {
2406  if (!scrollEl || scrollEl.dataset.olderHooked === '1') return;
2407  scrollEl.dataset.olderHooked = '1';
2408  let raf = 0;
2409  scrollEl.addEventListener('scroll', () => {
2410    if (raf) return;
2411    raf = requestAnimationFrame(() => {
2412      raf = 0;
2413      // Trigger when within 80px of the top
2414      if (scrollEl.scrollTop < 80) loadOlderPage();
2415    });
2416  });
2417}
2418
2419// "Bu mesaj silindi" tombstone'u (WhatsApp modeli) — hem geçmiş yüklerken
2420// (deleted_at dolu) hem canlı ws 'message_deleted' olayında kullanılır.
2421function tombstoneBubble(row) {
2422  const bubble = row.querySelector('.bubble');
2423  if (!bubble) return;
2424  bubble.classList.remove('bubble-attachment');
2425  // `msg-deleted` sınıfı görsel DEĞİL, sözleşme: eylem menüsü "bu mesaj silinmiş
2426  // mi" sorusunu bununla yanıtlıyor. Eskiden balonun içinde <i> aranıyordu ve
2427  // markdown'lı asistan cevapları yanlışlıkla "silinmiş" sayılabiliyordu.
2428  bubble.classList.add('msg-deleted');
2429  bubble.innerHTML = `<span style="font-style:italic;opacity:.6;">🚫 ${escapeHtml(t('chat.act.deleted'))}</span>`;
2430  row.querySelector('.msg-reactions')?.remove();
2431  // Dolandırıcılık etiketi balonun DIŞINDA; silinen mesajın uyarısı kalmasın.
2432  row.querySelector('.guv-etiket')?.remove();
2433}
2434
2435/** Sohbet başlığındaki "Daha fazla" (⋮) düğmesi — prototipte hiçbir şey
2436 *  yapmıyordu. Artık "Yıldızlı mesajlar" listesini açıyor. */
2437function wireStarredMenu() {
2438  // Düğmeyi aria-label METNİYLE aramak dile bağımlıydı: HTML'de o etiket
2439  // `data-i18n-attr="aria-label:ui.a11y.more"` taşıyor, yani dil İngilizceye
2440  // dönünce "Daha fazla" kaybolur ve "Yıldızlı mesajlar" düğmesi bir daha HİÇ
2441  // bağlanmazdı. Önce dilden bağımsız olan i18n anahtarına bakıyoruz.
2442  const btn = document.querySelector(
2443    '#chatView .header-actions [data-i18n-attr*="ui.a11y.more"]')
2444    || document.querySelector('#chatView .header-actions [aria-label="Daha fazla"]');
2445  if (!btn || btn.dataset.wired === '1') return;
2446  btn.dataset.wired = '1';
2447  btn.title = t('chat.act.starred_title');
2448  btn.addEventListener('click', (e) => { e.stopPropagation(); openStarredPanel(); });
2449}
2450
2451function renderHistoricalMessage(chatInner, msg) {
2452  if (msg.deleted_at) {
2453    // Silinmiş mesaj: içerik yerine tombstone; hizalama gönderene göre.
2454    const social = state.activeType === 'dm' || state.activeType === 'group';
2455    const mine = social
2456      ? (msg.sender_user_id != null && String(msg.sender_user_id) === String(_jwtUserId()))
2457      : msg.role === 'user';
2458    const row = document.createElement('div');
2459    row.className = 'msg-row' + (mine ? ' self' : '');
2460    row.dataset.trainDecorated = '1';
2461    row.dataset.msgId = msg.id;
2462    row.innerHTML = `
2463      <div class="msg-stack">
2464        <div class="bubble msg-deleted"><span style="font-style:italic;opacity:.6;">🚫 ${escapeHtml(t('chat.act.deleted'))}</span></div>
2465        <div class="bubble-meta">${formatChatTime(msg.created_at)}</div>
2466      </div>`;
2467    // Silinmiş ASİSTAN cevabı da bir turdu: üstündeki onay kartını öldürür.
2468    if (!social && msg.role === 'assistant') _asistanSatiriEkle(chatInner, row);
2469    else chatInner.appendChild(row);
2470    return;
2471  }
2472  const before = chatInner.lastElementChild;
2473  _renderMessageRow(chatInner, msg);
2474  // Satırı mesaj id'siyle etiketle → canlı 'message_deleted' olayı ve sağ-tık
2475  // "Mesajı sil" menüsü balonu bulabilsin.
2476  const added = chatInner.lastElementChild;
2477  if (added && added !== before && msg.id != null) {
2478    added.dataset.msgId = msg.id;
2479    // Alıntı bloğu, tepki çipleri, "İletildi"/"düzenlendi"/yıldız rozetleri.
2480    try { decorateRow(added, msg); } catch (e) { console.warn('[chat] decorate', e); }
2481    // DOLANDIRICILIK UYARISI — başkasının DM/grup mesajında sunucu `risk`
2482    // eklediyse balonun altına. Bu fonksiyon DÖRT çizim yolunun (ilk sayfa,
2483    // canlı yayın, eski sayfa, kaçan mesaj telafisi) ortak kapısı; etiket
2484    // birinde unutulamasın diye burada. Kendi mesajımda asla (sunucu da
2485    // göndermiyor; canlı yayında izleyen belli olmadığı için ikinci kapı).
2486    const _sosyal = state.activeType === 'dm' || state.activeType === 'group';
2487    if (msg.risk && _sosyal && !added.classList.contains('self')) {
2488      try {
2489        mesajRiskEtiketiEkle(added, msg, {
2490          sonra: () => _guvenlikDegisti(state.activeConvInfo),
2491        });
2492      } catch (e) { console.warn('[chat] risk etiketi', e); }
2493    }
2494  }
2495}
2496
2497function _renderMessageRow(chatInner, msg) {
2498  // DM/grup: gönderen kimliğine göre sağ (ben) / sol (diğer üye ya da ajan).
2499  const isSocial = state.activeType === 'dm' || state.activeType === 'group';
2500  if (isSocial && msg.role !== 'system') {
2501    const myId = _jwtUserId();
2502    const isMine = msg.sender_user_id != null && String(msg.sender_user_id) === String(myId);
2503    if (isMine) renderUserBubble(chatInner, msg);
2504    else appendPeerBubble(chatInner, msg);
2505    return;
2506  }
2507  if (msg.role === 'rep') {
2508    // Müşteri temsilcisi → GELEN baloncuk (sol), bot'tan ayrı stil, eğitim UI'sı yok.
2509    appendRepBubble(chatInner, msg);
2510  } else if (msg.role === 'user') {
2511    renderUserBubble(chatInner, msg);
2512  } else if (msg.role === 'assistant') {
2513    appendBotBubble(chatInner, msg);
2514  } else if (msg.role === 'system') {
2515    const row = document.createElement('div');
2516    row.className = 'msg-row system-row';
2517    row.innerHTML = `<div style="margin:8px auto;padding:5px 10px;background:rgba(58,28,89,0.06);border-radius:100px;font-size:11px;color:var(--text-secondary, #6B5F7E);">${escapeHtml(msg.text)}</div>`;
2518    chatInner.appendChild(row);
2519  }
2520}
2521
2522/**
2523 * Avatarı TIKLANABİLİR yapan öznitelikler — kişi kartını açar.
2524 *
2525 * ⛔ DİNLEYİCİ BURADA BAĞLANMIYOR, yalnız veri konuyor: balonlar altı ayrı
2526 * yerde ve canlı yayınla sonradan da çiziliyor. Tek dinleyici `kisi-karti.js`
2527 * içinde, `document` üzerinde duruyor — yarın eklenen bir balon da bu
2528 * öznitelikleri taşıdığı sürece kendiliğinden tıklanabilir olur.
2529 *
2530 * Kimliği olmayan kaynak (sistem mesajı, oturumu henüz yüklenmemiş "ben")
2531 * hiçbir öznitelik almaz: tıklanınca açılacak bir kart yoksa imleci el
2532 * yapmak da yanlış olurdu.
2533 */
2534function _kartNitelik(kaynak, ad) {
2535  const id = Number(kaynak && kaynak.id) || 0;
2536  if (!id) return '';
2537  const alan = (kaynak.kind === 'assistant') ? 'kart-asistan' : 'kart-kisi';
2538  return ` data-${alan}="${id}" data-kart-ad="${escapeHtml(ad || kaynak.name || '')}"`
2539       + ` role="button" tabindex="0" style="cursor:pointer;"`;
2540}
2541
2542/**
2543 * Balon avatarı — YÜKLENMİŞ PROFİL RESMİ varsa o, yoksa harf/simge.
2544 *
2545 * ⛔ ÖLÇÜLDÜ: sunucu gönderenin `avatar_url`ini zaten yolluyor
2546 * (`_attach_senders`, conversations_routes.py) ama balonlar onu HİÇ okumuyordu;
2547 * her zaman baş harf basılıyordu. Yani kullanıcı profil resmini yüklüyor,
2548 * listelerde ve profilde görüyor, DM balonlarında göremiyordu.
2549 *
2550 * ⛔ NEDEN YEREL KOPYA: aynı işi yapan `avatarIci` panel-app.js'te, ama
2551 * panel-app CHAT-FLOW'U İTHAL EDİYOR — buradan geri ithal etmek modül döngüsü
2552 * kurardı. Beş satır kopya, döngüden iyi.
2553 *
2554 * `yedekMetin` DÜZ METİNDİR (baş harf ya da 🤖); kaçış burada yapılıyor.
2555 */
2556function _balonAvatarIci(kaynak, yedekMetin) {
2557  const yedek = escapeHtml(String(yedekMetin || '?'));
2558  const url = kaynak && kaynak.avatar_url;
2559  if (!url) return yedek;
2560  // `onerror`: silinmiş/erişilemeyen görselde kırık resim ikonu yerine harfe
2561  // dön. Balon avatarı küçük; kırık ikon orada tanınmaz bir leke oluyor.
2562  return `<img src="${escapeHtml(url)}" alt="" loading="lazy"
2563    style="width:100%;height:100%;object-fit:cover;border-radius:inherit;display:block;"
2564    data-yedek="${yedek}"
2565    onerror="this.replaceWith(document.createTextNode(this.dataset.yedek||''))">`;
2566}
2567
2568// DM/grup: başka bir üyenin ya da davetli ajanın mesajı — sol baloncuk,
2569// üstünde gönderen adı (WhatsApp grubu gibi).
2570function appendPeerBubble(chatInner, msg) {
2571  const s = msg.sender || {};
2572  const isAgent = s.kind === 'assistant' || msg.sender_assistant_id != null;
2573  const name = s.name || (isAgent ? t('panel.assistant') : t('panel.member'));
2574  const tone = s.avatar_tone || ((Number(s.id) || 3) % 6) + 1;
2575  const row = document.createElement('div');
2576  row.className = 'msg-row';
2577  row.dataset.trainDecorated = '1';
2578  const attachmentHTML = msg.poll ? renderPoll(msg)
2579    : (_ekGovdesiVarMi(msg) ? renderAttachmentInBubble(msg, msg.text) : '');
2580  row.innerHTML = `
2581    <div class="avatar tone-${tone}" title="${escapeHtml(name)}"${_kartNitelik(s, name)}>${_balonAvatarIci(s, isAgent ? '🤖' : name.slice(0, 1).toUpperCase())}</div>
2582    <div class="msg-stack">
2583      <div class="bubble ${(_ekGovdesiVarMi(msg) || msg.poll) ? 'bubble-attachment' : ''}">
2584        <div style="font-size:10.5px;font-weight:700;color:${isAgent ? '#6B2387' : '#1F5BB0'};margin-bottom:3px;">
2585          ${escapeHtml(name)}${s.handle ? ` <span style="font-weight:500;color:#A89DB5;">@${escapeHtml(s.handle)}</span>` : ''}${isAgent ? ` · ${escapeHtml(t('chat.sender_assistant'))}` : ''}
2586        </div>
2587        ${attachmentHTML || '<span class="peer-text"></span>'}
2588      </div>
2589      <div class="bubble-meta">${formatChatTime(msg.created_at)}</div>
2590    </div>
2591  `;
2592  // `!msg.poll` ŞART: anket kartı balona yazıldıktan HEMEN sonra bu blok
2593  // `.bubble`ın içini düz metinle eziyordu — kendi anketini kuran kişi kartı
2594  // hiç göremiyor, oy da veremiyordu (asistan sohbetinde HERKES bu yoldan çizer).
2595  if (!_ekGovdesiVarMi(msg) && !msg.poll) {
2596    const el = row.querySelector('.peer-text');
2597    // Ajan/klon cevapları da model çıktısı → markdown/HTML'i biçimlendirerek bas.
2598    // Üye mesajları düz metin kalır ama çıplak URL'ler tıklanabilir olmalı
2599    // (escape linkifyText'in içinde — üye metni asla ham HTML olarak basılmaz).
2600    if (el && isAgent) el.innerHTML = renderClaudeText(msg.text || '');
2601    else if (el) el.innerHTML = vurgulaBahsetme(linkifyText(msg.text || ''));
2602  }
2603  chatInner.appendChild(row);
2604}
2605
2606function appendRepBubble(chatInner, msg) {
2607  const row = document.createElement('div');
2608  row.className = 'msg-row';
2609  row.dataset.trainDecorated = '1';   // inline-training observer bu satırı atlasın
2610  row.innerHTML = `
2611    <div class="avatar tone-3" title="${escapeHtml(t('chat.rep'))}">T</div>
2612    <div class="msg-stack">
2613      <div class="bubble">
2614        <div style="font-size:10.5px;font-weight:600;color:var(--success, #1B7A5E);margin-bottom:3px;display:flex;align-items:center;gap:4px;">
2615          <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>
2616          ${escapeHtml(t('chat.rep'))}
2617        </div>
2618        <span class="rep-text"></span>
2619      </div>
2620      <div class="bubble-meta">${formatChatTime(msg.created_at)}</div>
2621    </div>
2622  `;
2623  row.querySelector('.rep-text').innerHTML = vurgulaBahsetme(linkifyText(msg.text));
2624  chatInner.appendChild(row);
2625}
2626
2627// ---------- Canlı bağlantı (WebSocket) ----------
2628// Müşteri sohbeti REST ile GÖNDERİR; bu soket yalnızca BAŞKALARININ mesajlarını
2629// (operatör 'rep' yanıtları + 'system' notları) anında ALMAK için açılır.
2630// 'user'/'assistant' mesajları zaten REST yanıtında geldiği için ws'te yok sayılır.
2631/**
2632 * Tam yeniden çizim GÜVENLİ DEĞİLKEN, yalnız EKSİK olanları sona ekler.
2633 *
2634 * Eskiden bu durumlarda geç gelen taze liste tamamen ATILIYORDU ve mesaj
2635 * ekranda hiç görünmüyordu — kullanıcının "bildirim geldi ama sohbette yok"
2636 * dediği hâllerden biri buydu. Görüntüyü sarsmamak doğru bir kaygı ama
2637 * VERİYİ DÜŞÜRMEK çözüm değil: kaydırma konumuna dokunmadan ekliyoruz.
2638 *
2639 * Modül düzeyinde duruyor (eskiden `setActiveConversation` içinde bir
2640 * kapanıştı): tazeleme yeniden denemesi de aynı mantığı kullanıyor, iki kopya
2641 * ayrışırsa biri sessizce eskir.
2642 */
2643function _eksikleriEkle(chatInner, messages) {
2644  if (!chatInner || !Array.isArray(messages) || !messages.length) return 0;
2645  const mevcut = new Set(
2646    [...chatInner.querySelectorAll('[data-msg-id]')].map(el => String(el.dataset.msgId)));
2647  const enBuyuk = Math.max(0, ...[...mevcut].map(Number).filter(Number.isFinite));
2648  let eklendi = 0;
2649  messages
2650    .filter(m => m && m.id > enBuyuk && !mevcut.has(String(m.id)))
2651    .sort((a, b) => a.id - b.id)
2652    .forEach(m => { renderHistoricalMessage(chatInner, m); eklendi++; });
2653  // Kaydırma BİLEREK yok: kullanıcı yukarıda okuyorsa onu dibe fırlatmak
2654  // okuduğu yeri kaybettirir. Yeni mesaj altta duruyor, "aşağı in" düğmesi
2655  // (jumpDurumGuncelle) zaten onu haber veriyor.
2656  if (eklendi) state.seenLiveIds && messages.forEach(m => state.seenLiveIds.add(m.id));
2657  return eklendi;
2658}
2659
2660
2661/* ══════════════════ YENİLİK ŞERİDİ (mesajların hemen altında) ═════════════
2662 *
2663 * KURAL (panel geneli, bkz. ilerleme.js): kullanıcının başlattığı ve anında
2664 * bitmeyen hiçbir iş sessiz kalmaz. Sohbet açmak tam olarak böyle bir iş —
2665 * ekranda ÖNCE önbellekteki (bayat) sayfa beliriyor, taze sayfa arkadan
2666 * geliyor. O aradaki sessizlik kullanıcıya "yeni mesaj yok" diye okunuyordu:
2667 * listede güncel mesajı görüp sohbete giriyor, eski ekranı görüyor ve mesajın
2668 * geleceğini bilmiyordu.
2669 *
2670 * Şerit besteci kutusunun ÜSTÜNE, mesajların hemen altına oturur. 350 ms'den
2671 * kısa süren tazelemelerde hiç görünmez (yanıp sönme olmasın); uzarsa akan
2672 * belirsiz bir çubuk, başarısız olursa dokunulabilir bir "tekrar dene" olur.
2673 */
2674const YENILIK_GECIKMESI = 350;      // ms — bundan hızlı biten tazeleme sessizdir
2675
2676const yenilikSeridi = {
2677  _zamanlayici: 0,
2678  _convId: null,
2679  _tekrarZamani: 0,
2680
2681  _kap() {
2682    return document.querySelector('#chatView .composer-wrap')
2683      || document.querySelector('#chatView');
2684  },
2685
2686  _stil() {
2687    if (document.getElementById('mzYenilikStil')) return;
2688    const s = document.createElement('style');
2689    s.id = 'mzYenilikStil';
2690    s.textContent = `
2691      .mz-yenilik {
2692        display: flex; align-items: center; gap: 8px;
2693        margin: 0 auto 6px; padding: 5px 10px 6px;
2694        max-width: 420px; border-radius: 9px;
2695        background: var(--surface-2, #F3EEF8);
2696        border: 1px solid var(--hairline, #E7E0F0);
2697        font-size: 11.5px; color: var(--text-tertiary, #A89DB5);
2698      }
2699      .mz-yenilik .mz-yenilik-metin { flex: 1; }
2700      .mz-yenilik .mz-yenilik-cubuk {
2701        position: relative; height: 3px; width: 74px; border-radius: 2px;
2702        overflow: hidden; background: var(--hairline, #E7E0F0);
2703      }
2704      .mz-yenilik .mz-yenilik-cubuk::after {
2705        content: ''; position: absolute; inset: 0 auto 0 0; width: 40%;
2706        border-radius: 2px; background: var(--brand, #6B2387);
2707        animation: mzYenilikAkis 1.05s ease-in-out infinite;
2708      }
2709      @keyframes mzYenilikAkis {
2710        0%   { transform: translateX(-100%); }
2711        100% { transform: translateX(250%); }
2712      }
2713      .mz-yenilik.hata {
2714        color: var(--danger, #B83838);
2715        border-color: color-mix(in srgb, var(--danger, #B83838) 35%, transparent);
2716      }
2717      .mz-yenilik button {
2718        border: none; background: transparent; cursor: pointer; font: inherit;
2719        font-weight: 700; color: var(--brand, #6B2387); padding: 0;
2720      }
2721      @media (prefers-reduced-motion: reduce) {
2722        .mz-yenilik .mz-yenilik-cubuk::after { animation-duration: 3s; }
2723      }`;
2724    document.head.appendChild(s);
2725  },
2726
2727  _ciz(metin, { hata = false, tekrar = null } = {}) {
2728    this._stil();
2729    const kap = this._kap();
2730    if (!kap) return;
2731    let el = document.getElementById('mzYenilikSeridi');
2732    if (!el) {
2733      el = document.createElement('div');
2734      el.id = 'mzYenilikSeridi';
2735      el.className = 'mz-yenilik';
2736      el.setAttribute('role', 'status');
2737      el.setAttribute('aria-live', 'polite');
2738      kap.insertBefore(el, kap.firstChild);
2739    }
2740    el.className = 'mz-yenilik' + (hata ? ' hata' : '');
2741    el.innerHTML = '';
2742    if (!hata) {
2743      const c = document.createElement('span');
2744      c.className = 'mz-yenilik-cubuk';
2745      el.appendChild(c);
2746    }
2747    const m = document.createElement('span');
2748    m.className = 'mz-yenilik-metin';
2749    m.textContent = metin;
2750    el.appendChild(m);
2751    if (tekrar) {
2752      const b = document.createElement('button');
2753      b.type = 'button';
2754      b.textContent = t('chat.refresh_retry');
2755      b.addEventListener('click', tekrar);
2756      el.appendChild(b);
2757    }
2758  },
2759
2760  /** Tazeleme başladı — 350 ms'den uzun sürerse görünür ol. */
2761  baslat(convId, { hemen = false } = {}) {
2762    this.bitir();
2763    this._convId = convId;
2764    const goster = () => {
2765      if (this._convId !== convId || state.conversationId !== convId) return;
2766      this._ciz(t('chat.refreshing'));
2767    };
2768    if (hemen) goster();
2769    else this._zamanlayici = setTimeout(goster, YENILIK_GECIKMESI);
2770  },
2771
2772  bitir() {
2773    clearTimeout(this._zamanlayici); this._zamanlayici = 0;
2774    document.getElementById('mzYenilikSeridi')?.remove();
2775  },
2776
2777  /**
2778   * Tazeleme başarısız. SESSİZ KALMAK EN KÖTÜSÜ: ekranda bayat sohbet duruyor
2779   * ve kullanıcı bunu bilmiyor. Bir kez kendiliğinden yeniden deniyoruz
2780   * (kısa ağ kesintileri kullanıcıyı hiç rahatsız etmesin), sürerse elle
2781   * denenebilir bir düğme bırakıyoruz.
2782   */
2783  hata(convInfo) {
2784    const convId = convInfo?.id ?? state.conversationId;
2785    if (state.conversationId !== convId) return;
2786    // Elle denenirse bekleyen otomatik denemeyi İPTAL ET: yalnız sayacı
2787    // sıfırlamak, aynı sayfayı iki kez çekmek demekti.
2788    const elleDene = () => {
2789      clearTimeout(this._tekrarZamani); this._tekrarZamani = 0;
2790      _tazelemeyiTekrarDene();
2791    };
2792    this._ciz(t('chat.refresh_failed'), { hata: true, tekrar: elleDene });
2793    if (!this._tekrarZamani) {
2794      this._tekrarZamani = setTimeout(() => {
2795        this._tekrarZamani = 0;
2796        if (state.conversationId === convId) _tazelemeyiTekrarDene();
2797      }, 2500);
2798    }
2799  },
2800};
2801
2802
2803/** Tazeleme yeniden denemesi: sayfayı çek, EKSİK olanları ekle. */
2804async function _tazelemeyiTekrarDene() {
2805  const convId = state.conversationId;
2806  if (!convId) return;
2807  const chatInner = document.querySelector('#chatView .chat-inner');
2808  if (!chatInner) return;
2809  yenilikSeridi.baslat(convId, { hemen: true });
2810  let rows;
2811  try {
2812    rows = await api.get(`/api/conversations/${convId}/messages?limit=${PAGE_SIZE}`);
2813  } catch {
2814    yenilikSeridi.hata({ id: convId });
2815    return;
2816  }
2817  if (state.conversationId !== convId) return;
2818  // Hiç balon çizilmemişse (ilk yükleme de düşmüş) sohbeti baştan kur.
2819  if (!chatInner.querySelector('.msg-row[data-msg-id]')) {
2820    yenilikSeridi.bitir();
2821    if (state.activeConvInfo) setActiveConversation(state.activeConvInfo).catch(() => {});
2822    return;
2823  }
2824  if (Array.isArray(rows) && rows.some(m => m && m.encrypted)) {
2825    try { await satirlariCoz(rows); } catch { /* çözülemeyen satır ham kalır */ }
2826  }
2827  if (state.conversationId !== convId) return;
2828  const eklendi = _eksikleriEkle(chatInner, rows);
2829  yenilikSeridi.bitir();
2830  if (eklendi) state.scrollToBottom && state.scrollToBottom(false);
2831}
2832
2833
2834// Zamanlayıcı tik'i — EŞİK DEĞİL. Eşikler sunucudan geliyor ve her tik'te
2835// yeniden okunuyor; bu sabit yalnız "ne sıklıkta bakalım" sorusunun cevabı.
2836// Kısa tutuluyor ki 3 saniyelik bir ping ayarı bile gerçekten 3 saniyede
2837// çalışsın; maliyeti ağa çıkmayan bir karşılaştırma.
2838const _CANLI_TIK = 2000;
2839// Güvenlik ağının tik'i (aynı gerekçe). Ağa çıkma kararı eşiğe göre veriliyor.
2840const _AG_TIK = 3000;
2841
2842function _connectLiveSocket(convId) {
2843  try { if (state.liveSocket) { state.liveSocket.onclose = null; state.liveSocket.close(); } } catch {}
2844  state.liveSocket = null;
2845  clearInterval(state.liveKeepAlive); state.liveKeepAlive = 0;
2846  clearTimeout(state.liveYenidenBaglan); state.liveYenidenBaglan = 0;
2847  if (!convId) return;
2848  try {
2849    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
2850    const ws = new WebSocket(`${proto}://${location.host}/api/conversations/ws/${convId}`);
2851    // Kimlik İLK ÇERÇEVEDE gider (başlık gönderilemez; ?token= URL'si erişim
2852    // loglarına sızar — notifications ws ile aynı sözleşme). DM/grup odaları
2853    // üyelik doğrulaması istediği için bu çerçeve olmadan soket 4403 ile
2854    // kapanıyor ve yeni mesajlar ancak sayfa yenilenince görünüyordu.
2855    ws.onopen = () => {
2856      try { if (api.token) ws.send(JSON.stringify({ type: 'auth', token: api.token })); } catch {}
2857      state.liveGeriCekilme = 0;
2858      // Boşta kalan soketi vekil sunucu (Caddy) düşürmesin — bildirim soketiyle
2859      // aynı korunma. `'ping'` DÜZ METİN GÖNDERİLEMEZ: sunucunun döngüsü
2860      // `receive_json()` okuyor, geçersiz JSON çözümleme hatasıyla soketi
2861      // kapatırdı. `{type:'ping'}` çerçevesi metinsiz olduğu için sunucuda
2862      // sessizce atlanıyor (bkz. chat_ws: `if not text … continue`).
2863      clearInterval(state.liveKeepAlive);
2864      // ÖLÜ SOKET TESPİTİ. Yarı açık bir bağlantıda (uyku, ağ değişimi, vekil
2865      // sunucunun sessizce düşürmesi) `send` HATA VERMEZ ve `onclose` HİÇ
2866      // tetiklenmez: istemci bağlı sanır, yeni mesajlar hiç gelmez ve
2867      // kullanıcı ancak ekranlar arasında gezinip sohbeti yeniden açınca
2868      // eksikleri görür — bildirilen belirti tam olarak buydu.
2869      //
2870      // Çare: her ping'in cevabını (pong) bekle. Bir tur cevapsız kalırsa
2871      // soketi KENDİMİZ kapatıyoruz; bu `onclose`u tetikliyor, o da yeniden
2872      // bağlanma + kaçanları toplama zincirini çalıştırıyor.
2873      //
2874      // ⏱ SÜRELER SUNUCUDAN (`/api/config` → `canli.*`, bkz. sistem_ayar).
2875      // Kullanıcının gördüğü en kötü gecikmenin tavanı doğrudan bu iki sayı;
2876      // gömülü olsalardı her ayar denemesi bir yayın demekti. Zamanlayıcı SABİT
2877      // ve KISA bir tik'le dönüyor, eşikleri HER TİK'TE okuyor: böylece ayar
2878      // değişince açık duran sekme de bir sonraki tik'te uyum sağlıyor
2879      // (`/api/config` bu modülden sonra da gelebilir — yarış kapanıyor).
2880      state.sonPong = Date.now();
2881      state.sonPing = 0;
2882      state.liveKeepAlive = setInterval(() => {
2883        const simdi = Date.now();
2884        if (simdi - (state.sonPong || 0) > canliAyar('olu_sn', 20)) {
2885          try { ws.close(); } catch {}
2886          return;
2887        }
2888        if (simdi - (state.sonPing || 0) >= canliAyar('ping_sn', 8)) {
2889          state.sonPing = simdi;
2890          try { ws.send(JSON.stringify({ type: 'ping' })); } catch {}
2891        }
2892      }, _CANLI_TIK);
2893      // KOPUKKEN GELEN MESAJLARI TOPLA. Soket her koptuğunda (uyku, ağ
2894      // dalgalanması, vekil zaman aşımı) o aralıkta düşen mesajlar HİÇBİR
2895      // ZAMAN ekrana gelmiyordu: yayın kaçırılmış oluyor, yeniden bağlanan
2896      // soket ise yalnız BUNDAN SONRAKİ mesajları taşıyor. Kullanıcı sohbeti
2897      // yeniden açana ("menülerde gezinme") kadar eksik bir konuşma görüyordu.
2898      _kacanMesajlariTopla(convId);
2899    };
2900    ws.onmessage = (ev) => {
2901      let data; try { data = JSON.parse(ev.data); } catch { return; }
2902      // Herhangi bir çerçeve "bağlantı canlı" kanıtıdır, yalnız pong değil.
2903      state.sonPong = Date.now();
2904      if (data.type === 'pong') return;
2905      // ⛔ KENDİ YANKIM — ÇİFT BALONUN KÖKÜ.
2906      //
2907      // Sunucu mesajı yazar yazmaz yayına basıyor ve yayın GÖNDERENİ ELEMİYOR;
2908      // REST yanıtı ise sohbet turunun sonunda, saniyeler sonra dönüyor. O
2909      // pencerede bu sekme kendi mesajının yayınını alıp iyimser balonun
2910      // üstüne ikinci bir balon çiziyordu.
2911      //
2912      // Ölçüt "kim" değil "hangi SEKME" olmak zorunda:
2913      //   · rol elemesi Cevapsız Mesaj Nöbetçisi'nin geç gelen cevabını öldürür
2914      //     (o cevap YALNIZ yayından gelebiliyor),
2915      //   · kullanıcı kimliği (`sender_user_id`) elemesi ikinci sekmeyi de
2916      //     susturur: orada iyimser balon YOK, mesaj hiç görünmez. (Alan
2917      //     asistan sohbetinde de yazılıyor; sorun elemenin fazla geniş
2918      //     olması, alanın yokluğu değil.)
2919      // Sekme kimliği üçünü de doğru çözüyor. Damga yoksa (bayat panel, mobil,
2920      // dış istemci) hiçbir şey elenmiyor — güvenli yön.
2921      //
2922      // Mesajı BU sekme doğurduysa çizimi de bu sekme yapar: metin
2923      // (`realSend`), dosya/sesli not (`sendAttachment`) ve anket yollarının
2924      // üçü de gönderirken iyimser balonu kendisi basıyor.
2925      //
2926      // ⛔ YALNIZ `message` ÇERÇEVELERİ ELENİYOR. Damga her yayına konuyor ama
2927      // silme/tepki/yazıyor çerçevelerinde "gönderen zaten çizdi" varsayımı
2928      // geçerli değil — onları elemek, kendi sildiği mesajın mezar taşını
2929      // görmemek gibi sessiz kayıplar üretirdi.
2930      if (data.type === 'message' && data.message) {
2931        if (!(data.kaynak && data.kaynak === SEKME_KIMLIGI)) _onLiveMessage(data.message);
2932      }
2933      // Karşı taraf bir mesajı sildiyse açık balonu anında tombstone'a çevir.
2934      if (data.type === 'message_deleted' && data.message_id != null) {
2935        const row = document.querySelector(
2936          `#chatView .chat-inner .msg-row[data-msg-id="${data.message_id}"]`);
2937        if (row) tombstoneBubble(row);
2938      }
2939      // KAYBOLAN MESAJ: süresi doldu. Burada MEZAR TAŞI BIRAKILMAZ — satır
2940      // tamamen gider. "Bu mesaj silindi" yazmak, mesajın var olduğunu ele
2941      // verirdi; kaybolan mesajın izi kalmamalı (bkz. services/kaybolan.py).
2942      if (data.type === 'message_expired' && data.message_id != null) {
2943        document.querySelector(
2944          `#chatView .chat-inner .msg-row[data-msg-id="${data.message_id}"]`)?.remove();
2945        state.seenLiveIds?.delete?.(Number(data.message_id));
2946      }
2947      // Süre ayarı değişti: sistem mesajı zaten ayrı bir 'message' çerçevesiyle
2948      // düşüyor, burada yalnız başlık ipucu tazeleniyor.
2949      if (data.type === 'disappear_changed') _kaybolmaIpucu(data.seconds);
2950      // KARŞI TARAF ŞİFRELEMEYİ AÇTI/KAPATTI. Tazelemezsek bu ekran bayat
2951      // kalıyor ve gönderdiği HER mesaj sunucudan 400 alıyor — kullanıcı
2952      // yazdığının neden gitmediğini göremiyordu.
2953      if (data.type === 'e2e_changed') {
2954        import('./uctan-uca.js')
2955          .then(mod => mod.durum(state.conversationId))
2956          .then(d => { state.e2e = d; _e2eRozetiCiz(); })
2957          .catch(() => {});
2958      }
2959      // Anket oyu/kapanışı — kart yerinde güncellenir.
2960      if (data.type === 'poll_update' && data.poll) _onPollUpdate(data.poll);
2961      // CEVAP ÖNERİLERİ cevaptan SONRA arka planda üretiliyor (sözleşme
2962      // §3.8): o mesajın balonuna çipleri sonradan ekle.
2963      if (data.type === 'message_suggestions') _onerileriUygula(data);
2964      // Tepki / düzenleme / sabitleme / "yazıyor…" çerçeveleri chat-actions'a.
2965      try { onActionEvent(data); } catch (e) { console.warn('[chat] live action', e); }
2966    };
2967    // Bağlantı koparsa sessizce bırak (REST yine de çalışır); aktifse yeniden dene.
2968    // Bekleme ÜSTEL: ilk deneme 1 sn (eskiden sabit 4 sn — canlı sohbette uzun
2969    // bir kör nokta), sunucu gerçekten yoksa 30 sn'ye kadar açılır ki her sekme
2970    // saniyede bir el sıkışmaya çalışmasın.
2971    ws.onclose = () => {
2972      clearInterval(state.liveKeepAlive); state.liveKeepAlive = 0;
2973      if (state.liveSocket !== ws || state.conversationId !== convId) return;
2974      const n = state.liveGeriCekilme = Math.min((state.liveGeriCekilme || 0) + 1, 5);
2975      clearTimeout(state.liveYenidenBaglan);
2976      state.liveYenidenBaglan = setTimeout(() => {
2977        if (state.conversationId === convId) _connectLiveSocket(convId);
2978      }, Math.min(2 ** (n - 1), 30) * 1000);
2979    };
2980    state.liveSocket = ws;
2981  } catch { /* ws desteklenmiyorsa REST ile devam */ }
2982}
2983
2984/**
2985 * Canlı yayında KAÇAN mesajları REST ile tamamla.
2986 *
2987 * Soket kopukken gelen mesaj hiçbir yerden geri gelmiyordu: yayın anlıktır,
2988 * yeniden bağlanan soket geçmişi göndermez (bilerek — çift render olmasın).
2989 * Sonuç, ekranda duran sohbetin sessizce eskimesiydi.
2990 *
2991 * Ölçüt DOM'daki EN BÜYÜK `data-msg-id`: `seenLiveIds` yalnız canlı gelenleri
2992 * biliyor, geçmişten çizilenleri değil. Aynı uç (`/messages`) ilk sayfayla
2993 * birebir aynı zenginleştirmeyi (gönderen, alıntı, tepki, maskeler, engel
2994 * süzgeci) uyguladığı için balonlar geçmişten gelenlerle tutarlı çizilir.
2995 */
2996let _telafiSon = 0;
2997async function _kacanMesajlariTopla(convId) {
2998  if (!convId || state.conversationId !== convId) return;
2999  // Kısma: sekmeye her dönüşte + her yeniden bağlanmada çağrılıyor. İki saniye
3000  // içinde ikinci kez sormak aynı sayfayı iki kez çekmek demek.
3001  if (Date.now() - _telafiSon < 2000) return;
3002  // Gönderim UÇUŞTAYKEN telafi yapma: kullanıcının kendi mesajı önce iyimser
3003  // balon olarak çiziliyor ve id'sini ancak REST yanıtı gelince alıyor
3004  // (decorateRow). O aralıkta çekilen sayfa, id'siz balonu "eksik" sanıp aynı
3005  // mesajı ikinci kez çizerdi. Kısa süre sonra yeniden dene.
3006  if (state.inFlight) {
3007    setTimeout(() => _kacanMesajlariTopla(convId), 1500);
3008    return;
3009  }
3010  _telafiSon = Date.now();
3011  const chatInner = document.querySelector('#chatView .chat-inner');
3012  if (!chatInner) return;
3013  const mevcut = [...chatInner.querySelectorAll('.msg-row[data-msg-id]')]
3014    .map(el => parseInt(el.dataset.msgId, 10)).filter(Number.isFinite);
3015  // Henüz hiç mesaj çizilmediyse ilk sayfa YÜKLENİYOR demektir (ya da sohbet
3016  // gerçekten boş): telafiye gerek yok, `renderFirstPage` zaten taze veri çiziyor.
3017  if (!mevcut.length) return;
3018  const sonId = Math.max(...mevcut);
3019  const TELAFI = 50;
3020  let rows;
3021  try {
3022    rows = await api.get(`/api/conversations/${convId}/messages?limit=${TELAFI}`);
3023  } catch { return; }
3024  if (!Array.isArray(rows) || !rows.length) return;
3025  if (state.conversationId !== convId) return;           // arada sohbet değişti
3026  const yeni = rows.filter(m => m && m.id != null && m.id > sonId);
3027  if (!yeni.length) return;
3028  // Kopukluk `TELAFI`den uzun sürdüyse aradan mesaj DÜŞMÜŞ olabilir; yamalı bir
3029  // sohbet göstermektense sohbeti baştan yükle (kullanıcının elle yaptığının aynısı).
3030  if (rows.length >= TELAFI && rows[0].id > sonId) {
3031    if (state.activeConvInfo) setActiveConversation(state.activeConvInfo).catch(() => {});
3032    return;
3033  }
3034  if (!state.seenLiveIds) state.seenLiveIds = new Set();
3035  // Telafi yolu da ÇİZİM yoludur: çözmeyi atlayınca kopukluktan sonra gelen
3036  // mesajlar ham zarf olarak beliriyordu. Önbelleğe ham kopya yazılıyor
3037  // (çözülmüş metni `encrypted:1` ile saklamak bir sonraki açılışta
3038  // "çözülemedi" göstermek olurdu).
3039  const hamKopyalar = new Map(yeni.filter(m => m && m.encrypted).map(m => [m.id, { ...m }]));
3040  await satirlariCoz(yeni);
3041  let eklendi = 0;
3042  for (const m of yeni) {
3043    if (state.seenLiveIds.has(m.id)) continue;           // yayın arada yetişti
3044    // Telafi turu kurtarılmış cevabı getirmiş olabilir: not artık yalan
3045    // söylüyor. Kaldırmayı yalnız ws yoluna bırakmak, cevabın REST'ten
3046    // düştüğü her durumda notu ekranda BIRAKIRDI.
3047    if (m.role === 'assistant') _beklemeNotunuKaldir();
3048    if (chatInner.querySelector(`.msg-row[data-msg-id="${m.id}"]`)) continue;
3049    state.seenLiveIds.add(m.id);
3050    renderHistoricalMessage(chatInner, m);
3051    try { cacheAppendMessage(hamKopyalar.get(m.id) || m); } catch {}
3052    eklendi++;
3053  }
3054  if (!eklendi) return;
3055  try { presence.hideTyping(); } catch {}
3056  const son = yeni[yeni.length - 1];
3057  if (window.morfozBumpConversation) window.morfozBumpConversation(convId, son.text);
3058  state.yeniMesajIsareti && state.yeniMesajIsareti();
3059  state.scrollToBottom && state.scrollToBottom(false);
3060  if (state.activeType === 'dm' || state.activeType === 'group') _okunduIsaretle(convId);
3061}
3062
3063/**
3064 * Dışarıdan tetiklenen telafi. İKİ SOKET BAĞIMSIZ ÇÖKER: bildirim soketi
3065 * ayakta kalıp konuşma soketi ölebilir (ya da yarı-açık kalabilir — tarayıcı
3066 * `onclose` üretmez, `readyState` hâlâ OPEN görünür ama hiçbir çerçeve gelmez).
3067 * O durumda "yeni mesaj" olayı bildirim kanalından geliyor ama balon hiç
3068 * çizilmiyordu. panel-app bu olayı yakalayınca burayı çağırıyor.
3069 */
3070export function canliTelafiIste(convId, msgId) {
3071  const hedef = convId == null ? state.conversationId : convId;
3072  if (!hedef || String(hedef) !== String(state.conversationId)) return;
3073  const cizildiMi = () => (
3074    (state.seenLiveIds && state.seenLiveIds.has(msgId))
3075    || !!document.querySelector(`#chatView .chat-inner .msg-row[data-msg-id="${msgId}"]`)
3076  );
3077  const calis = () => {
3078    _kacanMesajlariTopla(state.conversationId);
3079    // Soket yarı-açık kalmış olabilir: kapalıysa yeniden kur.
3080    const s = state.liveSocket;
3081    if (!s || s.readyState > 1) _connectLiveSocket(state.conversationId);
3082  };
3083  // Belirli bir mesaj için çağrıldıysa ÖNCE konuşma soketine şans ver: iki olay
3084  // (bildirim kanalı + konuşma kanalı) neredeyse aynı anda geliyor ve sırası
3085  // garanti değil. Beklemeden telafiye girseydik SAĞLIKLI yolda her mesaj bir
3086  // de gereksiz REST turu doğururdu.
3087  if (msgId == null) { calis(); return; }
3088  if (cizildiMi()) return;
3089  setTimeout(() => {
3090    if (String(hedef) !== String(state.conversationId)) return;
3091    if (cizildiMi()) return;                       // yayın yetişti — telafi gereksiz
3092    calis();
3093  }, 1200);
3094}
3095
3096// ---- "Cevabınız hazırlanıyor" notu ----
3097// Sunucu `assistant_pending` döndürdüğünde çizilir: tur düştü, Cevapsız Mesaj
3098// Nöbetçisi devraldı, cevap birazdan ws üzerinden düşecek.
3099//
3100// NEDEN KIRMIZI HATA BALONU DEĞİL: eski davranış "Hata: …" yazıp orada
3101// bırakıyordu — kullanıcıya sanki her şey bitti, tekrar yazması gerek diyordu.
3102// Oysa arkada kurtarma sürüyor. Yanlış olan da tam buydu: müşteri aynı soruyu
3103// yeniden yazınca iki tur birden koşuyor.
3104function _beklemeNotunuKaldir() {
3105  const eski = document.getElementById('mzBeklemeNotu');
3106  if (eski) eski.remove();
3107}
3108
3109function _beklemeNotu(chatInner, convId) {
3110  if (!chatInner) return;
3111  // NOT SAHİBİNİ DENETLE. Yavaş dönen bir yanıt beklenirken kullanıcı sol
3112  // listeden başka bir sohbete geçmiş olabilir; denetlemeseydik "cevabınız
3113  // hazırlanıyor" notu ALAKASIZ bir konuşmaya düşerdi.
3114  if (convId != null && String(state.conversationId) !== String(convId)) return;
3115  _beklemeNotunuKaldir();                       // tek not yeter, üst üste binmesin
3116  const row = document.createElement('div');
3117  row.className = 'msg-row';
3118  row.id = 'mzBeklemeNotu';
3119  row.dataset.trainDecorated = '1';             // sahte "uyum" pili eklenmesin
3120  const govde = document.createElement('div');
3121  govde.className = 'msg-stack';
3122  const balon = document.createElement('div');
3123  balon.className = 'bubble';
3124  balon.style.cssText = 'background:rgba(148,148,148,0.10);border:1px dashed rgba(148,148,148,0.35);font-size:12px;opacity:.9';
3125  balon.textContent = t('chat.pending_reply');  // textContent: metin i18n'den, kaçış gereksiz
3126  govde.appendChild(balon);
3127  const avatar = document.createElement('div');
3128  avatar.className = 'avatar tone-4';
3129  avatar.textContent = '⋯';               // yatay üç nokta
3130  row.appendChild(avatar);
3131  row.appendChild(govde);
3132  // Not, nöbetçi kurtarana kadar asistanın TUR SINIRIDIR: sunucu bekleyen
3133  // onayları tur DÜŞSE de kapatıyor (services/modul_devam.py, "ESKİ ONAY
3134  // SORUSU"). Düz appendChild ile üstteki onay kartı canlı kalır, tıklanan
3135  // "evet" nöbetçinin "tekrar dener misiniz?" sorusuna cevap olarak giderdi.
3136  _asistanSatiriEkle(chatInner, row);
3137}
3138
3139function _onLiveMessage(msg) {
3140  if (!msg || msg.id == null) return;
3141  if (msg.conversation_id !== state.conversationId) return;     // başka konuşma
3142  const isSocial = state.activeType === 'dm' || state.activeType === 'group';
3143  if (isSocial) {
3144    // DM/grup: diğer üyeler + davetli ajanların mesajları ws'ten canlı gelir.
3145    // KENDİ mesajlarım hiç render edilmez (optimistik balon + REST yanıtı zaten
3146    // çizer; ws yayını REST cevabından ÖNCE gelebilir → yarış çift balon yapar).
3147    const myId = _jwtUserId();
3148    if (msg.sender_user_id != null && String(msg.sender_user_id) === String(myId)) return;
3149  }
3150  // ARTIK HİÇBİR ROL ELENMİYOR — tekilleme `seenLiveIds` ile yapılıyor.
3151  //
3152  // Eski süzgeç 'user' ve 'assistant'ı "nasılsa REST yanıtında gelir" diye
3153  // atıyordu. Bu iki varsayım da yanlıştı:
3154  //   • ASİSTAN: Cevapsız Mesaj Nöbetçisi cevabı dakikalar sonra, bambaşka
3155  //     bir süreçten yazabiliyor — o cevap YALNIZ buradan gelebilir.
3156  //   • KULLANICI: Asistan Havuzu'nda işletme sahibi BAŞKASININ sohbetini
3157  //     açık tutuyor; müşterinin sorusu ona hiçbir REST yanıtıyla gelmiyor.
3158  //     Süzgeç yüzünden sahibi cevabı görüyor ama soruyu göremiyordu.
3159  // Kendi mesajım çift çizilmiyor: REST yolu id'yi `seenLiveIds`e ekliyor.
3160  // ASİSTAN MESAJI ARTIK CANLI YAYINDAN DA GEÇİYOR. Eskiden elenmesinin
3161  // gerekçesi "nasılsa REST yanıtında gelir"di; bu, cevabın HER ZAMAN o
3162  // isteğin içinde üretildiğini varsayıyordu. Cevapsız Mesaj Nöbetçisi
3163  // (services/yanit_nobetcisi.py) cevabı dakikalar sonra, bambaşka bir
3164  // süreçten yazabiliyor — o cevap yalnız bu yoldan gelebilir. Süzgeç
3165  // kalsaydı, sunucu kurtarma turunu başarıyla tamamlasa bile ekranı açık
3166  // tutan müşteri hiçbir şey görmezdi.
3167  if (!state.seenLiveIds) state.seenLiveIds = new Set();
3168  if (state.seenLiveIds.has(msg.id)) return;                    // çift render koruması
3169  state.seenLiveIds.add(msg.id);
3170  // Beklenen cevap geldi: "hazırlanıyor" notu artık yalan söylüyor.
3171  if (msg.role === 'assistant') _beklemeNotunuKaldir();
3172  const chatInner = document.querySelector('#chatView .chat-inner');
3173  if (!chatInner) return;
3174  // ŞİFRELİ MESAJ ÖNCE ÇÖZÜLÜR. Çözemezsek metin `null` kalıyor ve balon
3175  // "çözülemedi" diye çiziliyor — base64 zarfı ekrana basmak en kötü seçenek
3176  // olurdu (hem çöp gösterir hem kullanıcıyı yanıltır).
3177  if (msg.encrypted) {
3178    // ÖNBELLEĞE HAM SATIR: `mesajiCoz` `text`i düz metinle değiştiriyor ama
3179    // `encrypted` 1 kalıyor. Çözülmüş metni o bayrakla önbelleğe yazmak,
3180    // bir sonraki açılışta kendi mesajını "çözülemedi" göstermek demekti.
3181    const ham = { ...msg };
3182    satirlariCoz([msg]).then(() => {
3183      renderHistoricalMessage(chatInner, msg);
3184      try { cacheAppendMessage(ham); } catch {}
3185    });
3186  } else {
3187    renderHistoricalMessage(chatInner, msg);
3188    try { cacheAppendMessage(msg); } catch {}
3189  }
3190  // Mesaj geldiyse yazma bitmiştir. Göstergeyi kendi 5 sn'lik sönmesine ya da
3191  // geç gelen `active:false` çerçevesine bırakmak, mesaj balonu ekranda
3192  // dururken başlıkta hâlâ "Ahmet yazıyor…" yazması demekti — kullanıcı gelmiş
3193  // mesajı okurken karşısının hâlâ yazdığını sanıp bekliyordu.
3194  try { presence.hideTyping(); } catch {}
3195  if (window.morfozBumpConversation) window.morfozBumpConversation(msg.conversation_id, msg.text);
3196  // KARŞI TARAFTAN gelen mesaj kullanıcının açık eylemi DEĞİL: dipteyse takip
3197  // eder, geçmişi okuyorsa yerinden etmez — bunun yerine "en alta in" düğmesine
3198  // okunmamış rozeti düşer. (Eskiden argümansız çağrılıyordu = koşulsuz dibe.)
3199  state.yeniMesajIsareti && state.yeniMesajIsareti();
3200  state.scrollToBottom && state.scrollToBottom(false);
3201  // Sohbet AÇIKKEN gelen mesaj okunmuş sayılır. Bu çağrı olmadan okundu suyu
3202  // yalnız sohbet AÇILIRKEN (chat-list) ilerliyor: karşı taraf sohbeti açık
3203  // tutup okumaya devam ederken GÖNDERENDEKİ tik hiç çift tike dönmüyordu.
3204  if (isSocial) _okunduIsaretle(msg.conversation_id);
3205}
3206
3207// Okundu işaretini KIS: karşılıklı hızlı yazışmada mesaj başına bir POST
3208// demekti. 3 sn'de bir yazmak yeterli — su seviyesi zaten "şu ana kadar
3209// okundu" anlamına gelen tek bir zaman damgası.
3210const OKUNDU_KISMA_MS = 3000;
3211let _okunduSon = 0;
3212let _okunduTimer = 0;
3213
3214/** Mesaj GERÇEKTEN görülebilir durumda mı? "Okundu" göndermenin tek kapısı.
3215 *
3216 *  İki ayrı koşul ve ikisi de gerekli:
3217 *   1. Sekme önde mi — arka plandaki sekmede okundu göndermek, kullanıcı mesajı
3218 *      hiç görmeden karşı tarafta çift tik yakar.
3219 *   2. SOHBET EKRANI açık mı — bu unutulmuştu. Panelde sol rail'den Cüzdan/
3220 *      Ayarlar'a geçince #chatView `display:none` oluyor ama state.conversationId
3221 *      ve canlı soket ayakta kalıyor; sekme de önde olduğu için 1. koşul geçiyordu.
3222 *      Sonuç: kullanıcı başka ekrandayken gelen mesaj "okundu" işaretleniyordu.
3223 *  Tek yerde topladık ki okundu gönderen her yol (canlı mesaj, sekmeye dönüş,
3224 *  hash ile açılış) aynı kapıdan geçsin — kapıyı çoğaltmak onu delmektir.
3225 */
3226export function sohbetGorunurMu() {
3227  if (document.visibilityState !== 'visible') return false;
3228  const v = document.getElementById('chatView');
3229  if (!v) return true;                    // ayrı bağlamda (embed) gömülü sohbet
3230  // offsetParent null → kendisi ya da bir atası display:none.
3231  return v.offsetParent !== null;
3232}
3233
3234function _okunduIsaretle(convId) {
3235  if (!convId || state.conversationId !== convId) return;
3236  // Sekmeye dönünce initChatFlow'daki `visibilitychange` kancası telafi ediyor.
3237  if (!sohbetGorunurMu()) return;
3238  const kalan = OKUNDU_KISMA_MS - (Date.now() - _okunduSon);
3239  if (kalan > 0) {
3240    if (_okunduTimer) return;                       // zaten kuyrukta
3241    _okunduTimer = setTimeout(() => { _okunduTimer = 0; _okunduIsaretle(convId); }, kalan);
3242    return;
3243  }
3244  _okunduSon = Date.now();
3245  api.post(`/api/conversations/${convId}/read`, {})
3246    .then(() => window.morfozRefreshNavBadges && window.morfozRefreshNavBadges())
3247    .catch(() => {});
3248}
3249
3250/**
3251 * Kanal mesajının teslim durumu: ✓ gönderildi · ✓✓ teslim · mavi ✓✓ okundu.
3252 *
3253 * YALNIZ DURUM VARSA ÇİZİLİR. Panel/web sohbetlerinde teslim diye bir kavram
3254 * yoktur (mesaj zaten ekranda); alan boşken gri bir tik basmak, hiç
3255 * gönderilmemiş bir kanal mesajı izlenimi verirdi. Alan yoksa (eski istemci
3256 * önbelleği, teslim takibi olmayan kanal) hiçbir şey eklenmez.
3257 */
3258function _teslimIsareti(msg) {
3259  const d = msg && msg.delivery_status;
3260  if (!d) return '';
3261  if (d === 'failed') {
3262    return ` <span title="${escapeHtml(t('chat.delivery.failed'))}" style="color:var(--danger, #B83838);">⚠</span>`;
3263  }
3264  const isaret = d === 'sent' ? '✓' : '✓✓';
3265  const renk = d === 'read' ? 'color:#1F8FE0;' : 'color:var(--text-tertiary);';
3266  const ipucu = d === 'read' ? t('chat.delivery.read')
3267              : (d === 'delivered' ? t('chat.delivery.delivered') : t('chat.delivery.sent'));
3268  return ` <span title="${escapeHtml(ipucu)}" style="${renk}">${isaret}</span>`;
3269}
3270
3271function renderUserBubble(chatInner, msg) {
3272  const row = document.createElement('div');
3273  row.className = 'msg-row self';
3274  // ANKET ekten ÖNCE: ikisi de balonun gövdesini kaplıyor ama anket kendi
3275  // kartını çiziyor. (Bir mesaj ikisini birden taşımıyor.)
3276  const attachmentHTML = msg.poll
3277    ? renderPoll(msg)
3278    : (_ekGovdesiVarMi(msg) ? renderAttachmentInBubble(msg, msg.text) : '');
3279  row.innerHTML = `
3280    <div class="avatar tone-1"${_kartNitelik(window.morfozMe, window.morfozMe?.full_name)}>${_balonAvatarIci(window.morfozMe, (window.morfozMe?.full_name || 'DK').slice(0,2).toUpperCase())}</div>
3281    <div class="msg-stack">
3282      <div class="bubble ${(_ekGovdesiVarMi(msg) || msg.poll) ? 'bubble-attachment' : ''}">${attachmentHTML || ''}</div>
3283      <div class="bubble-meta">${formatChatTime(msg.created_at)}</div>
3284    </div>
3285  `;
3286  // `!msg.poll` ŞART: anket kartı balona yazıldıktan HEMEN sonra bu blok
3287  // `.bubble`ın içini düz metinle eziyordu — kendi anketini kuran kişi kartı
3288  // hiç göremiyor, oy da veremiyordu (asistan sohbetinde HERKES bu yoldan çizer).
3289  if (!_ekGovdesiVarMi(msg) && !msg.poll) {
3290    // linkifyText: escape + çıplak URL'ler tıklanabilir (düz textContent
3291    // bağlantıları ölü bırakıyordu).
3292    row.querySelector('.bubble').innerHTML = vurgulaBahsetme(linkifyText(msg.text));
3293  }
3294  chatInner.appendChild(row);
3295}
3296
3297/** Tarih/saat biçimlendirmesi için etkin dilin BCP-47 etiketi. Gün/ay
3298 *  kısaltmaları elle dizilerde tutuluyordu; Intl'e bırakınca 'tr-TR' çıktısı
3299 *  BİREBİR aynı kalıyor ('Paz','Şub'…) ve İngilizce kendiliğinden geliyor. */
3300function _yerel() { return currentLang() === 'en' ? 'en-US' : 'tr-TR'; }
3301
3302function formatTime(iso) {
3303  if (!iso) return '';
3304  try {
3305    const d = new Date(iso.replace(' ', 'T') + 'Z');
3306    return d.toLocaleTimeString(_yerel(), { hour: '2-digit', minute: '2-digit' });
3307  } catch { return ''; }
3308}
3309
3310/** WhatsApp-style chat timestamp.
3311 *  Today        → "14:32"
3312 *  Yesterday    → "Dün 14:32"
3313 *  Within 7d    → "Salı 14:32"
3314 *  Same year    → "12 Mar 14:32"
3315 *  Older        → "12 Mar 2024 14:32"
3316 */
3317function formatChatTime(iso) {
3318  if (!iso) return '';
3319  try {
3320    const d = new Date(String(iso).replace(' ', 'T') + 'Z');
3321    if (isNaN(d.getTime())) return '';   // never render "Invalid Date" under a reply
3322    const now = new Date();
3323    const sameYear = d.getFullYear() === now.getFullYear();
3324    const day = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
3325    const diffDays = Math.round((day(now) - day(d)) / (24 * 3600 * 1000));
3326    const yer = _yerel();
3327    const hm = d.toLocaleTimeString(yer, { hour: '2-digit', minute: '2-digit' });
3328    if (diffDays === 0) return hm;
3329    if (diffDays === 1) return t('chat.time.yesterday_cap', { time: hm });
3330    if (diffDays < 7) {
3331      return `${d.toLocaleDateString(yer, { weekday: 'short' })} ${hm}`;
3332    }
3333    if (sameYear) {
3334      return `${d.getDate()} ${d.toLocaleDateString(yer, { month: 'short' })} ${hm}`;
3335    }
3336    return d.toLocaleString(yer, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
3337  } catch { return ''; }
3338}
3339
3340// =============== Sesli not kaydı ===============
3341// Kayıt düğmesi ataç düğmesinin YANINA enjekte ediliyor, HTML'e gömülmüyor:
3342// sohbet çubuğu birkaç farklı ekranda (asistan sohbeti, DM, grup) aynı
3343// bileşenden çiziliyor ve hepsinin şablonuna elle eklemek bir tanesini
3344// unutmaya açıktı.
3345const VN_MAX_MS = 15 * 60 * 1000;     // sunucudaki tavanla aynı
3346let _vnKayit = null;                   // { rec, chunks, stream, t0, timer, kutu }
3347
3348function _vnDesteklenirMi() {
3349  return !!(navigator.mediaDevices?.getUserMedia && window.MediaRecorder);
3350}
3351
3352/** Tarayıcının gerçekten üretebildiği ilk ses biçimi. Safari webm/opus
3353 *  DESTEKLEMİYOR (mp4/aac veriyor); sabit webm yazmak Safari'de kaydı
3354 *  sessizce boş bırakıyordu. */
3355function _vnBicim() {
3356  const adaylar = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
3357  for (const c of adaylar) {
3358    if (window.MediaRecorder?.isTypeSupported?.(c)) return c;
3359  }
3360  return '';
3361}
3362
3363function wireVoiceRecorder(composer, sendBtn, chatInner) {
3364  const atac = document.getElementById('attachBtn');
3365  if (!atac || !atac.parentNode) return;
3366  document.getElementById('voiceBtn')?.remove();
3367  if (!_vnDesteklenirMi()) return;      // destek yoksa düğmeyi HİÇ gösterme
3368
3369  const btn = document.createElement('button');
3370  btn.id = 'voiceBtn';
3371  btn.type = 'button';
3372  btn.className = atac.className;       // çubuktaki öteki düğmelerle aynı görünüm
3373  btn.title = t('chat.voice.record');
3374  btn.setAttribute('aria-label', btn.title);
3375  btn.textContent = '🎤';
3376  atac.parentNode.insertBefore(btn, atac.nextSibling);
3377
3378  btn.addEventListener('click', async () => {
3379    if (_vnKayit) { await _vnBitir(true, composer, sendBtn, chatInner); return; }
3380    await _vnBasla(btn, composer, sendBtn, chatInner);
3381  });
3382
3383  // ---- Anket düğmesi. Mikrofonun yanında, aynı enjeksiyon mantığıyla:
3384  // sohbet çubuğu birkaç ekranda aynı bileşenden çiziliyor, HTML'e gömmek
3385  // birini unutmaya açıktı.
3386  document.getElementById('pollBtn')?.remove();
3387  const anketBtn = document.createElement('button');
3388  anketBtn.id = 'pollBtn';
3389  anketBtn.type = 'button';
3390  anketBtn.className = atac.className;
3391  anketBtn.title = t('chat.poll.new');
3392  anketBtn.setAttribute('aria-label', anketBtn.title);
3393  anketBtn.textContent = '📊';
3394  btn.parentNode.insertBefore(anketBtn, btn.nextSibling);
3395  anketBtn.addEventListener('click', () => anketPenceresi(chatInner));
3396}
3397
3398async function _vnBasla(btn, composer, sendBtn, chatInner) {
3399  let stream;
3400  try {
3401    stream = await navigator.mediaDevices.getUserMedia({ audio: true });
3402  } catch {
3403    // İzin reddi ile "mikrofon yok" aynı hatayı veriyor; kullanıcıya
3404    // yapabileceği bir şey söyle.
3405    toast(t('chat.voice.permission'), 'error');
3406    return;
3407  }
3408  const mimeType = _vnBicim();
3409  const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
3410  const chunks = [];
3411  rec.addEventListener('dataavailable', (e) => { if (e.data?.size) chunks.push(e.data); });
3412  rec.start();
3413
3414  const kutu = document.createElement('div');
3415  kutu.className = 'vn-kayit';
3416  kutu.innerHTML = `<span class="vn-nokta"></span><span class="vn-sure">0:00</span>
3417    <button type="button" class="vn-iptal">${escapeHtml(t('action.cancel'))}</button>`;
3418  composer.parentNode?.insertBefore(kutu, composer);
3419  kutu.querySelector('.vn-iptal').addEventListener('click', (e) => {
3420    e.stopPropagation();
3421    _vnBitir(false, composer, sendBtn, chatInner);
3422  });
3423
3424  const t0 = Date.now();
3425  const timer = setInterval(() => {
3426    const gecen = Date.now() - t0;
3427    kutu.querySelector('.vn-sure').textContent = sesSuresi(gecen);
3428    // ÜST SINIRDA KENDİLİĞİNDEN GÖNDER: sunucu zaten kırpıyor, ama kullanıcı
3429    // 40 dakika konuşup 15 dakikasının gittiğini sonradan öğrenmemeli.
3430    if (gecen >= VN_MAX_MS) _vnBitir(true, composer, sendBtn, chatInner);
3431  }, 200);
3432
3433  btn.textContent = '⏹';
3434  btn.classList.add('recording');
3435  // KAYDIN SOHBETİ BAŞLANGIÇTA ÇİVİLENİYOR. Kullanıcı kayıt sürerken sol
3436  // listeden başka bir sohbete geçebiliyor; hedefi gönderim anında okumak,
3437  // Ayşe'ye söylenen sesi 200 kişilik gruba yollamak demekti.
3438  _vnKayit = { rec, chunks, stream, t0, timer, kutu, btn,
3439               convId: state.conversationId };
3440}
3441
3442async function _vnBitir(gonder, composer, sendBtn, chatInner) {
3443  const k = _vnKayit;
3444  if (!k) return;
3445  _vnKayit = null;
3446  clearInterval(k.timer);
3447  const sure = Date.now() - k.t0;
3448
3449  const bitti = new Promise((çöz) => k.rec.addEventListener('stop', çöz, { once: true }));
3450  try { k.rec.stop(); } catch {}
3451  await bitti;
3452  // MİKROFONU MUTLAKA BIRAK: kapatılmayan track sekmede kayıt göstergesini
3453  // yanık bırakıyor ve kullanıcı dinlendiğini sanıyor.
3454  k.stream.getTracks().forEach((tr) => tr.stop());
3455
3456  k.kutu.remove();
3457  k.btn.textContent = '🎤';
3458  k.btn.classList.remove('recording');
3459
3460  if (!gonder) return;
3461  if (sure < 1000) {                    // kazara dokunuş
3462    toast(t('chat.voice.too_short'), 'info');
3463    return;
3464  }
3465  // Sohbet değiştiyse GÖNDERME. Sessizce başka odaya düşmesindense
3466  // kullanıcıya söylemek doğrusu.
3467  if (k.convId != null && k.convId !== state.conversationId) {
3468    toast(t('chat.voice.wrong_chat'), 'error');
3469    return;
3470  }
3471  const tur = k.rec.mimeType || 'audio/webm';
3472  const uzanti = tur.includes('mp4') ? 'm4a' : tur.includes('ogg') ? 'ogg' : 'webm';
3473  const blob = new Blob(k.chunks, { type: tur.split(';')[0] });
3474  const dosya = new File([blob], `sesli-not.${uzanti}`, { type: tur.split(';')[0] });
3475  await sendAttachment(dosya, composer, sendBtn, chatInner, Math.min(sure, VN_MAX_MS));
3476}
3477
3478// =============== Attachment flow ===============
3479function wireAttachButton(composer, sendBtn, chatInner) {
3480  const btn = document.getElementById('attachBtn');
3481  if (!btn) return;
3482
3483  // Clone to drop any prototype listeners
3484  const fresh = btn.cloneNode(true);
3485  btn.parentNode.replaceChild(fresh, btn);
3486
3487  // Hidden file input
3488  let input = document.getElementById('chatFileInput');
3489  if (!input) {
3490    input = document.createElement('input');
3491    input.type = 'file';
3492    input.id = 'chatFileInput';
3493    // VIDEO DA SECILEBILIR. Eskiden `accept` listesinde video yoktu, yani
3494    // kullanici sohbete video EKLEYEMIYORDU bile -- secicide dosyalar sonuk
3495    // gorunuyordu. Bildirilen hatanin en dogrudan sebebi buydu.
3496    input.accept = 'image/*,video/*,application/pdf,.doc,.docx,.xls,.xlsx,'
3497      + '.ppt,.pptx,.txt,.md,.csv';
3498    input.style.display = 'none';
3499    document.body.appendChild(input);
3500  }
3501
3502  fresh.addEventListener('click', () => input.click());
3503  input.addEventListener('change', async (e) => {
3504    const file = e.target.files?.[0];
3505    e.target.value = '';        // reset for next pick
3506    await dosyayiOnaylaVeGonder(file, composer, sendBtn, chatInner);
3507  });
3508}
3509
3510// ---------------- Dosya: önizleme + onay + sürükle-bırak ----------------
3511// SUNUCUDAKİ İKİ TAVANLA AYNI (routes/conversations_routes.py):
3512// MAX_CHAT_FILE_MB = 25, MAX_CHAT_VIDEO_MB = 200. Panel her eke 25 MB
3513// uyguluyordu, yani sunucunun videoya tanıdığı hakkı hiç kullanmıyordu:
3514// telefon videosu tipik olarak 30-150 MB, kullanıcı "çok büyük" görüp
3515// vazgeçiyordu. Tür `file.type`tan okunuyor; tarayıcı türü boş bırakırsa
3516// (bazı sürükle-bırak kaynaklarında olur) dar tavan geçerli kalır —
3517// sunucu zaten dosya İMZASINA bakıp kendi kararını veriyor.
3518const MAX_EK_MB = 25;
3519const MAX_VIDEO_MB = 200;
3520const ekTavaniMb = (file) =>
3521  String(file && file.type || '').toLowerCase().startsWith('video/') ? MAX_VIDEO_MB : MAX_EK_MB;
3522
3523/** Ataç ve sürükle-bırak İKİ yolun da TEK kapısı: boyut kontrolü → önizleme →
3524 *  onay → gönderim. Tek yerde toplandı çünkü iki yolun ayrı davranması
3525 *  (birinde önizleme var, diğerinde yok) tam olarak kullanıcının şikayet ettiği
3526 *  tutarsızlık olurdu. */
3527async function dosyayiOnaylaVeGonder(file, composer, sendBtn, chatInner) {
3528  if (!file) return;
3529  const tavanMb = ekTavaniMb(file);
3530  if (file.size > tavanMb * 1024 * 1024) {
3531    toast(t('chat.file.too_large', { mb: tavanMb }), 'error');
3532    return;
3533  }
3534  const karar = await dosyaOnizlemeAc(file, (composer.value || '').trim());
3535  if (!karar.gonder) return;
3536  composer.value = karar.aciklama;         // sendAttachment açıklamayı buradan okur
3537  await sendAttachment(file, composer, sendBtn, chatInner, 0, karar.tekSeferlik);
3538}
3539
3540/** Tek seferlik görüntüleme YALNIZ kişi/grup sohbetinde ve yalnız fotoğraf/video
3541 *  için sunulur. Asistan sohbetinde alıcı bir yazılım — "bir kez açılır" sözünün
3542 *  karşılığı yok. Belgede de yok: PDF'i tek seferlik göstermek, indirilebilir bir
3543 *  dosyaya tutulamayacak bir söz vermek olurdu. */
3544function _tekSeferlikSunulurMu(file) {
3545  const sosyal = state.activeType === 'dm' || state.activeType === 'group';
3546  const tur = (file?.type || '');
3547  return sosyal && (tur.startsWith('image/') || tur.startsWith('video/'));
3548}
3549
3550function _boyutMetni(b) {
3551  if (b < 1024) return `${b} B`;
3552  if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`;
3553  return `${(b / 1024 / 1024).toFixed(1)} MB`;
3554}
3555
3556/** Gönderimden ÖNCE dosyayı göster ve onay iste.
3557 *  → {gonder: bool, aciklama: string, tekSeferlik: bool} */
3558function dosyaOnizlemeAc(file, mevcutAciklama = '') {
3559  return new Promise((resolve) => {
3560    injectDosyaOnizlemeStilleri();
3561    const gorsel = (file.type || '').startsWith('image/');
3562    const tekSeferlikVar = _tekSeferlikSunulurMu(file);
3563    // Görsel önizlemesi için nesne URL'si — kapanışta MUTLAKA serbest bırakılır,
3564    // yoksa her önizleme sekmede kalıcı bellek tutar.
3565    const url = gorsel ? URL.createObjectURL(file) : null;
3566
3567    const ov = document.createElement('div');
3568    ov.className = 'dosya-onizleme';
3569    ov.innerHTML = `
3570      <div class="do-kutu" role="dialog" aria-modal="true" aria-label="${escapeHtml(t('chat.file.preview'))}">
3571        <div class="do-baslik">
3572          <span>${escapeHtml(t('chat.file.check_before_send'))}</span>
3573          <button class="do-kapat" aria-label="${escapeHtml(t('action.cancel'))}">✕</button>
3574        </div>
3575        <div class="do-govde">
3576          ${gorsel
3577            ? `<img class="do-gorsel" src="${url}" alt="${escapeHtml(file.name)}">`
3578            : `<div class="do-dosya"><div class="do-ikon">${fileTypeIcon(file.type)}</div>
3579                 <div class="do-ad">${escapeHtml(file.name)}</div></div>`}
3580          <div class="do-bilgi">
3581            <span class="do-isim">${escapeHtml(file.name)}</span>
3582            <span class="do-boyut">${_boyutMetni(file.size)}</span>
3583          </div>
3584          <textarea class="do-aciklama" rows="2"
3585            placeholder="${escapeHtml(t('chat.file.caption_ph'))}"></textarea>
3586          ${tekSeferlikVar ? `
3587            <label class="do-tek">
3588              <input type="checkbox" class="do-tek-kutu">
3589              <span class="do-tek-metin">
3590                <span class="do-tek-ad">① ${escapeHtml(t('chat.once.send_label'))}</span>
3591                <span class="do-tek-not">${escapeHtml(t('chat.once.send_note'))}</span>
3592              </span>
3593            </label>` : ''}
3594        </div>
3595        <div class="do-alt">
3596          <button class="do-vazgec">${escapeHtml(t('action.cancel'))}</button>
3597          <button class="do-gonder">${escapeHtml(t('action.send'))}</button>
3598        </div>
3599      </div>`;
3600    document.body.appendChild(ov);
3601
3602    const aciklamaEl = ov.querySelector('.do-aciklama');
3603    aciklamaEl.value = mevcutAciklama;
3604    setTimeout(() => aciklamaEl.focus(), 30);
3605
3606    let bitti = false;
3607    const kapat = (gonder) => {
3608      if (bitti) return;
3609      bitti = true;
3610      const aciklama = (aciklamaEl.value || '').trim();
3611      const tekSeferlik = !!ov.querySelector('.do-tek-kutu')?.checked;
3612      if (url) URL.revokeObjectURL(url);
3613      ov.remove();
3614      document.removeEventListener('keydown', tus);
3615      resolve({ gonder, aciklama, tekSeferlik });
3616    };
3617    const tus = (e) => {
3618      if (e.key === 'Escape') { e.preventDefault(); kapat(false); }
3619      // Ctrl/Cmd+Enter = gönder (açıklama alanında düz Enter satır başı yapsın).
3620      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); kapat(true); }
3621    };
3622    document.addEventListener('keydown', tus);
3623    ov.querySelector('.do-gonder').addEventListener('click', () => kapat(true));
3624    ov.querySelector('.do-vazgec').addEventListener('click', () => kapat(false));
3625    ov.querySelector('.do-kapat').addEventListener('click', () => kapat(false));
3626    ov.addEventListener('click', (e) => { if (e.target === ov) kapat(false); });
3627  });
3628}
3629
3630/** Sohbet alanına dosya sürüklemeyi etkinleştirir.
3631 *
3632 *  Sayaç kullanılıyor çünkü `dragenter`/`dragleave` HER alt eleman için ayrı
3633 *  ateşleniyor: sayaç olmadan fare bir balonun üstünden geçerken katman
3634 *  titriyor ve "bırak" alanı kayboluyordu.
3635 */
3636function wireDragDrop(composer, sendBtn, chatInner) {
3637  const zone = document.getElementById('chatView');
3638  if (!zone || zone.dataset.dropHooked === '1') return;
3639  zone.dataset.dropHooked = '1';
3640  injectDosyaOnizlemeStilleri();
3641
3642  const katman = document.createElement('div');
3643  katman.className = 'birak-katmani';
3644  katman.innerHTML = '<div class="bk-ic"><div class="bk-ikon">⬇</div>'
3645    + `<div class="bk-metin">${escapeHtml(t('chat.file.drop_here'))}</div></div>`;
3646  zone.appendChild(katman);
3647
3648  let sayac = 0;
3649  const dosyaSurukleniyorMu = (e) =>
3650    [...(e.dataTransfer?.types || [])].includes('Files');
3651
3652  zone.addEventListener('dragenter', (e) => {
3653    if (!dosyaSurukleniyorMu(e)) return;   // metin sürüklemede katman açılmasın
3654    e.preventDefault();
3655    sayac++;
3656    katman.classList.add('acik');
3657  });
3658  zone.addEventListener('dragover', (e) => {
3659    if (!dosyaSurukleniyorMu(e)) return;
3660    e.preventDefault();                     // ŞART: olmazsa 'drop' hiç ateşlenmez
3661    e.dataTransfer.dropEffect = 'copy';
3662  });
3663  zone.addEventListener('dragleave', (e) => {
3664    if (!dosyaSurukleniyorMu(e)) return;
3665    sayac = Math.max(0, sayac - 1);
3666    if (!sayac) katman.classList.remove('acik');
3667  });
3668  zone.addEventListener('drop', async (e) => {
3669    if (!dosyaSurukleniyorMu(e)) return;
3670    e.preventDefault();
3671    sayac = 0;
3672    katman.classList.remove('acik');
3673    const file = e.dataTransfer?.files?.[0];
3674    if (!file) return;
3675    if (e.dataTransfer.files.length > 1) {
3676      toast(t('chat.file.single_only'), '');
3677    }
3678    await dosyayiOnaylaVeGonder(file, composer, sendBtn, chatInner);
3679  });
3680
3681  // Sohbetin DIŞINA bırakılan dosyayı tarayıcı yeni sekmede açar ve kullanıcı
3682  // paneli kaybeder. Pencere genelinde varsayılanı susturuyoruz.
3683  ['dragover', 'drop'].forEach(ev =>
3684    window.addEventListener(ev, (e) => {
3685      if (dosyaSurukleniyorMu(e) && !zone.contains(e.target)) e.preventDefault();
3686    }));
3687}
3688
3689function injectDosyaOnizlemeStilleri() {
3690  if (document.getElementById('dosyaOnizlemeStilleri')) return;
3691  const s = document.createElement('style');
3692  s.id = 'dosyaOnizlemeStilleri';
3693  s.textContent = `
3694    .dosya-onizleme{position:fixed;inset:0;background:rgba(28,12,44,.5);z-index:10001;
3695      display:flex;align-items:center;justify-content:center;padding:18px;}
3696    .do-kutu{background:var(--surface,#fff);color:inherit;border-radius:16px;
3697      width:min(420px,100%);max-height:90vh;display:flex;flex-direction:column;
3698      overflow:hidden;box-shadow:0 20px 60px rgba(28,12,44,.34);}
3699    .do-baslik{display:flex;align-items:center;justify-content:space-between;
3700      padding:12px 15px;border-bottom:1px solid var(--border,#E6DEF0);
3701      font-size:13.5px;font-weight:700;}
3702    .do-kapat{border:none;background:none;cursor:pointer;font-size:15px;color:inherit;opacity:.7;}
3703    .do-govde{padding:14px 15px;overflow:auto;display:flex;flex-direction:column;gap:10px;}
3704    .do-gorsel{width:100%;max-height:320px;object-fit:contain;border-radius:10px;
3705      background:var(--surface-2,#F3EEF8);}
3706    .do-dosya{display:flex;align-items:center;gap:12px;padding:16px;
3707      background:var(--surface-2,#F3EEF8);border-radius:10px;}
3708    .do-ikon{font-size:34px;line-height:1;}
3709    .do-ad{font-size:13px;font-weight:600;word-break:break-word;}
3710    .do-bilgi{display:flex;justify-content:space-between;gap:10px;font-size:11.5px;opacity:.7;}
3711    .do-isim{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
3712    .do-boyut{flex-shrink:0;}
3713    .do-aciklama{width:100%;border:1px solid var(--border,#E6DEF0);border-radius:9px;
3714      padding:8px 10px;font-family:inherit;font-size:13px;resize:vertical;
3715      background:var(--surface-2,#F3EEF8);color:inherit;}
3716    /* Tek seferlik anahtarı: onay kutusu + NEDEN'ini anlatan alt satır.
3717       Alt satır süs değil — "bir kez açılır" sözünün sınırı (ekran görüntüsü
3718       engellenemez) burada yazılı olmazsa kullanıcı fazlasını sanır. */
3719    .do-tek{display:flex;align-items:flex-start;gap:9px;
3719cursor:pointer;
3720      padding:9px 11px;border:1px solid var(--border,#E6DEF0);border-radius:10px;
3721      background:var(--surface-2,#F7F3FB);}
3722    .do-tek input{margin-top:2px;accent-color:#6B2387;cursor:pointer;}
3723    .do-tek-metin{display:flex;flex-direction:column;gap:2px;min-width:0;}
3724    .do-tek-ad{font-size:13px;font-weight:600;}
3725    .do-tek-not{font-size:11px;opacity:.72;line-height:1.4;}
3726
3727    .do-alt{display:flex;justify-content:flex-end;gap:8px;padding:12px 15px;
3728      border-top:1px solid var(--border,#E6DEF0);}
3729    .do-alt button{border:none;border-radius:9px;padding:8px 18px;font-size:13px;
3730      font-weight:600;cursor:pointer;font-family:inherit;}
3731    .do-vazgec{background:var(--surface-2,#F3EEF8);color:inherit;}
3732    .do-gonder{background:#6B2387;color:#fff;}
3733
3734    .birak-katmani{position:absolute;inset:0;z-index:20;display:none;
3735      align-items:center;justify-content:center;background:rgba(107,35,135,.10);
3736      backdrop-filter:blur(2px);pointer-events:none;}
3737    .birak-katmani.acik{display:flex;}
3738    .bk-ic{border:2.5px dashed #6B2387;border-radius:16px;padding:26px 34px;
3739      background:var(--surface,#fff);color:inherit;text-align:center;font-size:13.5px;
3740      font-weight:600;box-shadow:0 10px 34px rgba(58,28,89,.20);}
3741    .bk-ikon{font-size:30px;line-height:1;margin-bottom:6px;}
3742
3743    /* Anket kurma penceresi (dosya önizlemesiyle aynı iskelet) */
3744    #anketKur .do-govde{display:flex;flex-direction:column;gap:8px;}
3745    #anketKur input[type=text],#anketKur #akSoru,#anketKur .ak-opt{
3746      width:100%;padding:9px 11px;border:1px solid var(--border,#E6DEF0);
3747      border-radius:9px;font-family:inherit;font-size:13px;color:inherit;
3748      background:var(--surface,#fff);}
3749    #anketKur #akSoru{font-weight:600;}
3750    #anketKur #akOpts{display:flex;flex-direction:column;gap:6px;}
3751    #anketKur .ak-ekle{align-self:flex-start;border:none;background:transparent;
3752      color:var(--brand, #6B2387);font-weight:600;font-size:12.5px;cursor:pointer;
3753      font-family:inherit;padding:2px 0;}
3754    #anketKur .ak-ekle:disabled{opacity:.45;cursor:default;}
3755    #anketKur .ak-cok{display:flex;align-items:center;gap:7px;font-size:12.5px;}
3756    #anketKur .ak-not{font-size:11px;opacity:.7;}
3757  `;
3758  document.head.appendChild(s);
3759}
3760
3761// voiceMs > 0 ise bu bir SESLİ NOT (kaydedilmiş), 0 ise sıradan bir dosya eki.
3762// Sunucu da aynı ayrımı yapıyor ve ayrıca türü doğruluyor — buradan gelen
3763// süreye tek başına güvenmiyor.
3764async function sendAttachment(file, composer, sendBtn, chatInner, voiceMs = 0, tekSeferlik = false) {
3765  if (state.inFlight) return;
3766  state.inFlight = true;
3767  sendBtn.disabled = true;
3768
3769  const caption = (composer.value || '').trim();
3770
3771  // 1. User bubble showing the attachment (optimistic — show local preview)
3772  const localUrl = URL.createObjectURL(file);
3773  const userRow = document.createElement('div');
3774  userRow.className = 'msg-row self';
3775  userRow.innerHTML = `
3776    <div class="avatar tone-1"${_kartNitelik(window.morfozMe, window.morfozMe?.full_name)}>${_balonAvatarIci(window.morfozMe, (window.morfozMe?.full_name || 'DK').slice(0,2).toUpperCase())}</div>
3777    <div class="msg-stack">
3778      <div class="bubble bubble-attachment">${renderAttachmentInBubble({
3779        attachment_url: localUrl,
3780        attachment_name: file.name,
3781        attachment_mime: file.type,
3782        attachment_size: file.size,
3783        voice_ms: voiceMs || null,
3784        // Gönderen kendi tek seferlik medyasını görebilir (sunucu da öyle
3785        // davranıyor); rozet yine de basılsın ki ne gönderdiğini bilsin.
3786        view_once: tekSeferlik || undefined,
3787        view_once_mine: tekSeferlik || undefined,
3788      }, caption)}</div>
3789      <div class="bubble-meta">${escapeHtml(t('chat.file.uploading'))}</div>
3790    </div>
3791  `;
3792  chatInner.appendChild(userRow);
3793  state.scrollToBottom && state.scrollToBottom();
3794  composer.value = '';
3795  composer.style.height = 'auto';
3796
3797  // 2. Typing indicator
3798  const typingRow = makeTypingIndicator();
3799  chatInner.appendChild(typingRow);
3800  state.scrollToBottom && state.scrollToBottom();
3801
3802  // 3. Upload + receive bot reply
3803  try {
3804    const convId = await ensureConversation();
3805    const fd = new FormData();
3806    fd.append('conversation_id', String(convId));
3807    fd.append('caption', caption);
3808    if (voiceMs) fd.append('voice_ms', String(voiceMs));
3809    if (tekSeferlik) fd.append('view_once', '1');
3810    fd.append('file', file);
3811    const r = await api.upload('/api/conversations/messages/upload', fd);
3812
3813    // SOHBET DEĞİŞTİ Mİ? realSend'deki kapının aynısı: yükleme sürerken başka
3814    // sohbete geçildiyse ekrandaki kap artık o sohbetin, A'nın cevabı B'ye
3815    // çizilemez. Yalnız kimlikle anahtarlanan yan etkiler kalıyor (önbellek +
3816    // sol liste); iyimser balon zaten koptu, blob adresi yine de bırakılıyor.
3817    if (state.conversationId !== convId) {
3818      URL.revokeObjectURL(localUrl);
3819      if (r.user_message) cacheAppendMessage(r.user_message);
3820      if (r.assistant_message) cacheAppendMessage(r.assistant_message);
3821      if (window.morfozBumpConversation) {
3822        const _ekOnizleme = `📎 ${t('chat.file.file')}`;
3823        window.morfozBumpConversation(convId, (r.assistant_message && r.assistant_message.text)
3824          || (r.user_message && (r.user_message.text || _ekOnizleme)) || _ekOnizleme);
3825      }
3826      return;
3827    }
3828
3829    // Replace optimistic preview with server URL + persisted timestamp
3830    if (r.user_message?.attachment_url) {
3831      const meta = userRow.querySelector('.bubble-meta');
3832      if (meta) meta.textContent = formatChatTime(r.user_message.created_at);
3833      const att = userRow.querySelector('.attachment-card');
3834      if (att && r.user_message.attachment_url) {
3835        const yeni = r.user_message.attachment_url;
3836        // Görsel eki artık <button.att-lightbox> (yeni sekme yok) — dosya eki
3837        // hâlâ <a download>. İKİSİNİ de güncelle, yoksa yükleme bittikten
3838        // sonra balon blob: URL'sinde kalıyor ve sayfa yenilenince kırılıyordu.
3839        const btn = att.querySelector('button.att-lightbox');
3840        if (btn) btn.dataset.attUrl = yeni;
3841        const a = att.querySelector('a.att-link');
3842        if (a) a.href = yeni;
3843        const img = att.querySelector('img.att-image');
3844        if (img) img.src = yeni;
3845        // Sesli notun kaynağı data-*'ta duruyor; güncellenmezse balon
3846        // sayfa yenilenene kadar iptal edilmiş blob: URL'sinde kalır.
3847        if (att.classList.contains('voice')) att.dataset.voiceUrl = yeni;
3848      }
3849      URL.revokeObjectURL(localUrl);
3850    }
3851
3852    typingRow.remove();
3853    if (r.user_message) {
3854      cacheAppendMessage(r.user_message);
3855      yabanciSeridiniKaldir();        // ek göndermek de cevaptır (bkz. realSend)
3856      // İyimser ek balonunu gerçek mesaj id'siyle eşle. BU SATIR OLMADAN ekle
3857      // gönderilen mesaja yanıt/yıldız/tepki HİÇ uygulanamıyordu (satırda
3858      // data-msg-id yok → menü mesajı bulamıyor) ve tik de hiç görünmüyordu.
3859      try { decorateRow(userRow, r.user_message); } catch {}
3860    }
3861    if (r.assistant_message && !(state.seenLiveIds && state.seenLiveIds.has(r.assistant_message.id))) {
3862      // ÇİFT BALON KORUMASI İKİ YÖNLÜ OLMAK ZORUNDA: sunucu cevabı önce
3863      // sokete basıp SONRA HTTP yanıtını döndürüyor, yani ws çerçevesi bu
3864      // koda ulaşmadan ÖNCE gelebiliyor. Asistan mesajları ws süzgecinden
3865      // geçmeye başladığı an, tek yönlü koruma aynı cevabı iki kez çizerdi.
3866      if (!state.seenLiveIds) state.seenLiveIds = new Set();
3867      state.seenLiveIds.add(r.assistant_message.id);
3868      _safeAppendBot(chatInner, r.assistant_message, 'chat-send');
3869      cacheAppendMessage(r.assistant_message);
3870      // Asistanın cevabı da yanıtlanabilir/yıldızlanabilir olmalı: id'yi hemen
3871      // iliştir, kullanıcı sayfa yenilemeyi beklemesin.
3872      try { decorateRow(chatInner.lastElementChild, r.assistant_message); } catch {}
3873    } else if (r.assistant_pending) {
3874      // Tur düştü ama müşteri cevapsız BIRAKILMADI: nöbetçi kurtarmayı
3875      // devraldı, cevap (ya da özür + tekrar deneme isteği) ws'ten düşecek.
3876      _beklemeNotu(chatInner, convId);
3877    }
3878    {
3879      const _ekOnizleme = `📎 ${t('chat.file.file')}`;
3880      const lastText = (r.assistant_message && r.assistant_message.text)
3881        || (r.user_message && (r.user_message.text || _ekOnizleme)) || _ekOnizleme;
3882      if (window.morfozBumpConversation) window.morfozBumpConversation(state.conversationId, lastText);
3883    }
3884    // GECİKMELİ sonuç (saniyeler sonra düşer): kullanıcı bu arada geçmişi
3885    // okumaya çıkmış olabilir. Dipteyse takip eder, değilse yerinden etmez —
3886    // bunun yerine "en alta in" düğmesinde okunmamış rozeti yanar.
3887    state.yeniMesajIsareti && state.yeniMesajIsareti();
3888    state.scrollToBottom && state.scrollToBottom(false);
3889  } catch (e) {
3890    reportClientError('chat-upload', e, { conversation_id: state.conversationId });
3891    typingRow.remove();
3892    const errRow = document.createElement('div');
3893    errRow.className = 'msg-row';
3894    errRow.dataset.trainDecorated = '1';   // hata balonuna sahte "uyum" pili eklenmesin
3895    errRow.innerHTML = `
3896      <div class="avatar tone-4">!</div>
3897      <div class="msg-stack"><div class="bubble" style="background:rgba(184,56,56,0.08);color:var(--danger, #8E2A2A);border:1px solid rgba(184,56,56,0.2);">⚠ ${t('chat.file.upload_error', { error: escapeHtml(e.message) })}</div></div>
3898    `;
3899    chatInner.appendChild(errRow);
3900    // GECİKMELİ sonuç (saniyeler sonra düşer): kullanıcı bu arada geçmişi
3901    // okumaya çıkmış olabilir. Dipteyse takip eder, değilse yerinden etmez —
3902    // bunun yerine "en alta in" düğmesinde okunmamış rozeti yanar.
3903    state.yeniMesajIsareti && state.yeniMesajIsareti();
3904    state.scrollToBottom && state.scrollToBottom(false);
3905  } finally {
3906    state.inFlight = false;
3907    sendBtn.disabled = composer.value.trim().length === 0;
3908    composer.focus();
3909  }
3910}
3911
3912// Anket kurma penceresi. Ayrı bir modal dosyası açmıyoruz: tek ekranlık,
3913// sohbete özel bir form ve buradaki `injectAttachmentStyles` zaten yükleniyor.
3914async function anketPenceresi(chatInner) {
3915  const convId = await ensureConversation();
3916  if (!convId) return;
3917  document.getElementById('anketKur')?.remove();
3918
3919  const ov = document.createElement('div');
3920  ov.id = 'anketKur';
3921  ov.className = 'dosya-onizleme';          // aynı karartma/kutu iskeleti
3922  const satir = (i) => `
3923    <input class="ak-opt" data-i="${i}" maxlength="120"
3924           placeholder="${escapeHtml((t('chat.poll.option_n')).replace('%s', i + 1))}">`;
3925  ov.innerHTML = `
3926    <div class="do-kutu" role="dialog" aria-modal="true"
3927         aria-label="${escapeHtml(t('chat.poll.new'))}">
3928      <div class="do-baslik">
3929        <span>${escapeHtml(t('chat.poll.new'))}</span>
3930        <button class="do-kapat" aria-label="${escapeHtml(t('action.cancel'))}">✕</button>
3931      </div>
3932      <div class="do-govde">
3933        <input id="akSoru" maxlength="300"
3934               placeholder="${escapeHtml(t('chat.poll.question'))}">
3935        <div id="akOpts">${satir(0)}${satir(1)}</div>
3936        <button type="button" id="akEkle" class="ak-ekle">+ ${escapeHtml(t('chat.poll.add_option'))}</button>
3937        <label class="ak-cok"><input type="checkbox" id="akCok">
3938          ${escapeHtml(t('chat.poll.multi'))}</label>
3939        <div class="ak-not">${escapeHtml(t('chat.poll.public_warning'))}</div>
3940      </div>
3941      <div class="do-alt">
3942        <button class="do-vazgec">${escapeHtml(t('action.cancel'))}</button>
3943        <button class="do-gonder" id="akGonder">${escapeHtml(t('chat.poll.create'))}</button>
3944      </div>
3945    </div>`;
3946  document.body.appendChild(ov);
3947
3948  const kapat = () => ov.remove();
3949  ov.querySelector('.do-kapat').addEventListener('click', kapat);
3950  ov.querySelector('.do-vazgec').addEventListener('click', kapat);
3951  ov.addEventListener('click', (e) => { if (e.target === ov) kapat(); });
3952
3953  ov.querySelector('#akEkle').addEventListener('click', () => {
3954    const kutu = ov.querySelector('#akOpts');
3955    const n = kutu.querySelectorAll('.ak-opt').length;
3956    if (n >= 12) return;                    // sunucudaki tavanla aynı
3957    kutu.insertAdjacentHTML('beforeend', satir(n));
3958    if (n + 1 >= 12) ov.querySelector('#akEkle').disabled = true;
3959  });
3960
3961  ov.querySelector('#akGonder').addEventListener('click', async () => {
3962    const soru = ov.querySelector('#akSoru').value.trim();
3963    const secenekler = [...ov.querySelectorAll('.ak-opt')]
3964      .map((i) => i.value.trim()).filter(Boolean);
3965    if (!soru || secenekler.length < 2) {
3966      toast(t('chat.poll.need_two'), 'error');
3967      return;
3968    }
3969    const btn = ov.querySelector('#akGonder');
3970    btn.disabled = true;
3971    try {
3972      const r = await api.call('/api/conversations/polls', {
3973        method: 'POST',
3974        body: { conversation_id: convId, question: soru,
3975                options: secenekler, multi: ov.querySelector('#akCok').checked },
3976      });
3977      kapat();
3978      // Kendi balonumuz canlı yayından GERİ GELMİYOR (gönderen elenir),
3979      // o yüzden burada çiziliyor.
3980      if (r?.message) { renderUserBubble(chatInner, r.message); state.scrollToBottom?.(); }
3981    } catch (err) {
3982      toast(err?.message || t('chat.poll.create_failed'), 'error');
3983      btn.disabled = false;
3984    }
3985  });
3986  ov.querySelector('#akSoru').focus();
3987}
3988
3989// =============== Anket ===============
3990// Sonuçlar HERKESE açık (WhatsApp da böyle) — bu kartta açıkça yazıyor,
3991// çünkü "gizli oylama" sanan biri hassas bir soru sorabilir.
3992function renderPoll(msg) {
3993  const p = msg.poll;
3994  if (!p) return '';
3995  const toplam = p.options.reduce((a, o) => a + (o.count || 0), 0);
3996  const satirlar = p.options.map((o) => {
3997    // Yüzde OY sayısına göre (kişi sayısına değil): çoklu seçimlide
3998    // yüzdelerin toplamı 100'ü aşabilir, WhatsApp'ta da öyle.
3999    const oran = toplam ? Math.round((o.count / toplam) * 100) : 0;
4000    return `
4001      <button type="button" class="pl-opt${o.mine ? ' mine' : ''}"
4002              data-poll="${p.id}" data-opt="${o.id}"
4003              ${p.closed ? 'disabled' : ''}>
4004        <span class="pl-bar" style="width:${oran}%"></span>
4005        <span class="pl-tick">${o.mine ? '✓' : ''}</span>
4006        <span class="pl-text">${escapeHtml(o.text)}</span>
4007        <span class="pl-count">${o.count}</span>
4008      </button>`;
4009  }).join('');
4010  const kisi = p.voter_count === 1
4011    ? t('chat.poll.one_voter')
4012    : (t('chat.poll.voters')).replace('%s', p.voter_count);
4013  return `
4014    <div class="poll-card" data-poll-card="${p.id}" data-multi="${p.multi ? 1 : 0}">
4015      <div class="pl-q">📊 ${escapeHtml(p.question)}</div>
4016      <div class="pl-sub">${p.multi ? escapeHtml(t('chat.poll.multi'))
4017                                    : escapeHtml(t('chat.poll.single'))}
4018        · ${escapeHtml(t('chat.poll.public'))}</div>
4019      <div class="pl-opts">${satirlar}</div>
4020      <div class="pl-foot">${escapeHtml(kisi)}${p.closed
4021        ? ' · ' + escapeHtml(t('chat.poll.closed')) : ''}</div>
4022    </div>`;
4023}
4024
4025/**
4026 * Canlı oy güncellemesi.
4027 *
4028 * `mine` YAYINDA GELMİYOR (sunucu bilerek çıkarıyor — yoksa herkesin ekranında
4029 * başkasının oyu kendisininmiş gibi görünürdü). Bu yüzden kendi işaretlerimizi
4030 * karttan okuyup yeni özete geri yazıyoruz; aksi halde başkası oy verdiğinde
4031 * kendi tikimiz kaybolurdu.
4032 */
4033function _onPollUpdate(poll) {
4034  const kart = document.querySelector(`[data-poll-card="${poll.id}"]`);
4035  if (!kart) return;
4036  const benimkiler = new Set([...kart.querySelectorAll('.pl-opt.mine')]
4037    .map((b) => Number(b.dataset.opt)));
4038  const birlesik = {
4039    ...poll,
4040    options: poll.options.map((o) => ({ ...o, mine: benimkiler.has(o.id) })),
4041  };
4042  const yeni = document.createElement('div');
4043  yeni.innerHTML = renderPoll({ poll: birlesik });
4044  kart.replaceWith(yeni.firstElementChild);
4045}
4046
4047function _wirePolls() {
4048  if (document.body.dataset.pollsWired) return;
4049  document.body.dataset.pollsWired = '1';
4050  document.addEventListener('click', async (e) => {
4051    const btn = e.target.closest?.('.pl-opt');
4052    if (!btn || btn.disabled) return;
4053    e.preventDefault();
4054    const kart = btn.closest('.poll-card');
4055    const pollId = btn.dataset.poll;
4056    const optId = Number(btn.dataset.opt);
4057    // Çoklu seçim: işaretliler + tıklanan (varsa çıkar). Tek seçim: yalnız
4058    // tıklanan — zaten işaretliyse oyu geri çekiyoruz.
4059    const cokMu = kart?.dataset.multi === '1';
4060    let secili = [...(kart?.querySelectorAll('.pl-opt.mine') || [])]
4061      .map((b) => Number(b.dataset.opt));
4062    if (cokMu) {
4063      secili = secili.includes(optId) ? secili.filter((x) => x !== optId)
4064                                      : [...secili, optId];
4065    } else {
4066      secili = secili.includes(optId) ? [] : [optId];
4067    }
4068    try {
4069      const r = await api.call(`/api/conversations/polls/${pollId}/vote`, {
4070        method: 'POST', body: { option_ids: secili },
4071      });
4072      if (r?.poll && kart) {
4073        const yeni = document.createElement('div');
4074        yeni.innerHTML = renderPoll({ poll: r.poll });
4075        kart.replaceWith(yeni.firstElementChild);
4076      }
4077    } catch (err) {
4078      toast(err?.message || t('chat.poll.vote_failed'), 'error');
4079    }
4080  });
4081}
4082
4083/** 0:07 — sesli not süresi. Sunucudaki _ses_suresi_yazisi ile aynı biçim. */
4084function sesSuresi(ms) {
4085  const sn = Math.max(1, Math.round((ms || 0) / 1000));
4086  return `${Math.floor(sn / 60)}:${String(sn % 60).padStart(2, '0')}`;
4087}
4088
4089/** Balonun gövdesini EK ÇİZİCİSİ mi dolduruyor?
4090 *
4091 *  Tek başına `attachment_url`e bakmak YETMEZ: tek seferlik mesajda adres
4092 *  yoktur (sunucu bilerek göndermiyor) ama balonda yine de bir kart çizilir.
4093 *  Bu ayrım tek bir yerde durmazsa balon "boş metin" dalına düşer ve özellik
4094 *  ekranda hiç görünmez — yazılmış ama görünmeyen bir özellik.
4095 */
4096function _ekGovdesiVarMi(msg) {
4097  return !!(msg && (msg.attachment_url || msg.view_once));
4098}
4099
4100/** TEK SEFERLİK balonu.
4101 *
4102 *  Adres yanıtta YOKTUR (sunucu göndermiyor) — burada çizilen şey bir kilit
4103 *  değil, gerçekten elimizde olmayan bir medyanın yerine geçen karttır. Alıcı
4104 *  "Görüntüle"ye basınca `POST /messages/{id}/open-once` adresi BİR KEZ döner.
4105 *
4106 *  Üç durum var: (1) gönderen ya da az önce açan → medya çizilir, üstünde ①
4107 *  rozeti; (2) alıcı henüz açmadı → açma kartı; (3) alıcı açtı ya da medya
4108 *  silindi → "açıldı", geri dönüşü yok. */
4109function _tekSeferlikKart(msg, caption) {
4110  const acildi = !!msg.view_once_opened;
4111  const govde = acildi
4112    ? `<div class="ts-kart ts-bitti">
4113         <span class="ts-ikon">①</span>
4114         <span class="ts-metin">
4115           <span class="ts-ad">${escapeHtml(t('chat.once.opened'))}</span>
4116           <span class="ts-not">${escapeHtml(t('chat.once.opened_note'))}</span>
4117         </span>
4118       </div>`
4119    : `<button type="button" class="ts-kart ts-ac" data-once-id="${Number(msg.id) || 0}">
4120         <span class="ts-ikon">①</span>
4121         <span class="ts-metin">
4122           <span class="ts-ad">${escapeHtml(t('chat.once.view'))}</span>
4123           <span class="ts-not">${escapeHtml(t('chat.once.view_note'))}</span>
4124         </span>
4125       </button>`;
4126  return `${govde}${caption ? `<div class="att-caption">${linkifyText(caption)}</div>` : ''}`;
4127}
4128
4129/** Ek boyutu: '840 KB' / '12.4 MB'. Video kartı ve dosya kartı AYNI
4130 *  yardımcıyı kullanıyor — iki ayrı formül iki farklı sayı gösterirdi. */
4131function _ekBoyutMetni(msg) {
4132  const kb = Math.max(1, Math.round((msg.attachment_size || 0) / 1024));
4133  return kb > 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb + ' KB';
4134}
4135
4136function renderAttachmentInBubble(msg, caption = '') {
4137  // TEK SEFERLİK: medya adresi yoksa (= sunucu bu izleyiciden gizledi) normal
4138  // ek kartı ÇİZİLEMEZ — `src="#"` kırık görsel demek olurdu.
4139  if (msg.view_once && !msg.attachment_url) return _tekSeferlikKart(msg, caption);
4140
4141  // SESLİ NOT: ses DOSYASINDAN önce bakılır. İkisi de audio/* ama sesli not
4142  // oynatıcı şeridi, eklenen bir mp3 ise dosya satırı olarak çiziliyor.
4143  // Ayrımı voice_ms yapıyor (bkz. messages_partition._EXTRA_COLS).
4144  if (msg.voice_ms) {
4145    const url = msg.attachment_url || '';
4146    // Sunucu metne "🎤 0:07" yazıyor — sohbet listesi önizlemesi ve push
4147    // bildirimi bunu kullanıyor, yani gerekli. Ama BALONDA oynatıcının altında
4148    // ikinci kez göstermek tekrar olurdu: yalnız kullanıcının y
4148azdığı gerçek
4149    // bir açıklama varsa alt yazı çizilir.
4150    const gercekAciklama = (caption || '').startsWith('🎤 ') ? '' : caption;
4151    caption = gercekAciklama;
4152    return `
4153      <div class="attachment-card voice" data-voice-url="${escapeHtml(url)}"
4154           data-voice-ms="${Number(msg.voice_ms) || 0}">
4155        <button type="button" class="vn-play" aria-label="${escapeHtml(t('chat.voice.play'))}">▶</button>
4156        <div class="vn-track"><div class="vn-fill"></div></div>
4157        <span class="vn-time">${sesSuresi(msg.voice_ms)}</span>
4158      </div>
4159      ${caption ? `<div class="att-caption">${linkifyText(caption)}</div>` : ''}
4160    `;
4161  }
4162  // VIDEO: kendi karti. Eskiden video jenerik "dosya satiri" olarak
4163  // ciziliyordu -- indirilecek bir ek gibi. Sohbette paylasilan videonun
4164  // yerinde oynamamasi, onu paylasmanin anlamini buyuk olcude yok ediyor.
4165  //
4166  // `preload="metadata"`: ilk kare ve sure icin YETERLI, tum dosyayi
4167  // indirmiyor. 200 MB'lik bir klibi sohbet acilir acilmaz cekmek, mobil
4168  // veriyi sessizce yakardi.
4169  //
4170  // `playsinline`: iOS Safari aksi halde videoyu ZORLA tam ekran aciyor ve
4171  // kullanici sohbetten kopuyor.
4172  if ((msg.attachment_mime || '').startsWith('video/')) {
4173    const vurl = msg.attachment_url || '';
4174    return `
4175      <div class="attachment-card video">
4176        <video class="att-video" controls preload="metadata" playsinline
4177               src="${escapeHtml(vurl)}"></video>
4178        ${msg.view_once ? `<span class="ts-rozet" title="${escapeHtml(t('chat.once.badge_title'))}">①</span>` : ''}
4179        <div class="att-video-meta">${escapeHtml(msg.attachment_name || t('chat.file.unnamed'))} · ${_ekBoyutMetni(msg)}</div>
4180        ${caption ? `<div class="att-caption">${linkifyText(caption)}</div>` : ''}
4181      </div>
4182    `;
4183  }
4184  const isImage = (msg.attachment_mime || '').startsWith('image/');
4185  const sizeText = _ekBoyutMetni(msg);
4186  const url = msg.attachment_url || '#';
4187  const name = escapeHtml(msg.attachment_name || t('chat.file.unnamed'));
4188
4189  if (isImage) {
4190    // Görsel YENİ SEKMEDE açılmaz — sohbetin içinde lightbox modalında büyür
4191    // (bkz. _wireImageLightbox). Sekme açmak sohbetten koparıyordu ve mobilde
4192    // geri dönmek zahmetliydi. <a> yerine <button>: klavyeyle de erişilebilir.
4193    return `
4194      <div class="attachment-card image">
4195        <button type="button" class="att-link att-lightbox" data-att-url="${url}"
4196                data-att-name="${name}" title="${escapeHtml(t('chat.file.click_to_zoom'))}">
4197          <img class="att-image" src="${url}" alt="${name}" />
4198          ${msg.view_once ? `<span class="ts-rozet" title="${escapeHtml(t('chat.once.badge_title'))}">①</span>` : ''}
4199        </button>
4200        ${caption ? `<div class="att-caption">${linkifyText(caption)}</div>` : ''}
4201      </div>
4202    `;
4203  }
4204  // Generic file card
4205  const icon = fileTypeIcon(msg.attachment_mime);
4206  return `
4207    <div class="attachment-card file">
4208      <a class="att-link" href="${url}" target="_blank" rel="noopener" download="${name}">
4209        <span class="att-icon">${icon}</span>
4210        <span class="att-meta">
4211          <span class="att-name">${name}</span>
4212          <span class="att-size">${sizeText}</span>
4213        </span>
4214      </a>
4215      ${caption ? `<div class="att-caption">${linkifyText(caption)}</div>` : ''}
4216    </div>
4217  `;
4218}
4219
4220// =============== Görsel lightbox (sohbet ekleri) ===============
4221// Tek delegasyonlu dinleyici: balonlar sürekli yeniden çiziliyor (geçmiş
4222// yükleme, canlı mesaj, iyimser balon), her karta tek tek dinleyici bağlamak
4223// yeni render'da kaybolurdu.
4224function _wireImageLightbox() {
4225  if (document.body.dataset.attLightboxWired) return;
4226  document.body.dataset.attLightboxWired = '1';
4227  _injectLightboxStyles();
4228  document.addEventListener('click', (e) => {
4229    const btn = e.target.closest?.('.att-lightbox');
4230    if (!btn) return;
4231    e.preventDefault();
4232    _openImageLightbox(btn.dataset.attUrl, btn.dataset.attName || t('chat.file.image'));
4233  });
4234  // TEK SEFERLİK AÇMA — aynı delegasyon: kartlar sürekli yeniden çiziliyor.
4235  document.addEventListener('click', (e) => {
4236    const btn = e.target.closest?.('.ts-ac');
4237    if (!btn) return;
4238    e.preventDefault();
4239    _tekSeferlikAc(btn);
4240  });
4241}
4242
4243/** "Görüntüle"ye basıldı: adresi sunucudan iste (BU BİR KEZ olur) ve göster.
4244 *
4245 *  Uyarı ONAY olarak ÖNCE soruluyor: yanlışlıkla dokunmak medyayı harcamamalı.
4246 *  Kartın kendisi de anında "açıldı"ya dönüyor — istek başarılı olduktan sonra,
4247 *  çünkü ağ hatasında hakkı yenmiş gibi göstermek kullanıcıyı kandırmak olurdu. */
4248async function _tekSeferlikAc(btn) {
4249  const id = parseInt(btn.dataset.onceId, 10);
4250  if (!Number.isFinite(id) || btn.dataset.busy === '1') return;
4251  if (!confirm(t('chat.once.open_confirm'))) return;
4252  btn.dataset.busy = '1';
4253  try {
4254    const r = await api.post(`/api/conversations/messages/${id}/open-once`, {});
4255    const gorsel = (r.attachment_mime || '').startsWith('image/');
4256    // Kartı "açıldı"ya çevir. Açıklama satırı KARDEŞ eleman olduğu için
4257    // yalnız düğmenin kendisi değişiyor — alt yazı yerinde kalır.
4258    btn.outerHTML = _tekSeferlikKart({ id, view_once: true, view_once_opened: true }, '');
4259    if (gorsel && r.attachment_url) {
4260      _openImageLightbox(r.attachment_url, r.attachment_name || t('chat.file.image'));
4261    } else if (r.attachment_url) {
4262      window.open(r.attachment_url, '_blank', 'noopener');
4263    }
4264  } catch (e) {
4265    btn.dataset.busy = '';
4266    toast(e.message, 'error');
4267  }
4268}
4269
4270// =============== Sesli not oynatıcı ===============
4271// TEK bir Audio nesnesi paylaşılıyor: her balona ayrı <audio> koymak, uzun bir
4272// sohbette onlarca medya öğesi açıp belleği şişirirdi ve WhatsApp'taki gibi
4273// "yeni birine basınca öteki sussun" davranışı da kendiliğinden gelmezdi.
4274let _vnSes = null;
4275let _vnKart = null;
4276
4277function _vnDurdur() {
4278  if (_vnSes) { _vnSes.pause(); }
4279  if (_vnKart) {
4280    _vnKart.querySelector('.vn-play').textContent = '▶';
4281    _vnKart.classList.remove('playing');
4282  }
4283  _vnKart = null;
4284}
4285
4286function _wireVoiceNotes() {
4287  if (document.body.dataset.voiceNotesWired) return;
4288  document.body.dataset.voiceNotesWired = '1';
4289  document.addEventListener('click', (e) => {
4290    const kart = e.target.closest?.('.attachment-card.voice');
4291    if (!kart) return;
4292    e.preventDefault();
4293    const url = kart.dataset.voiceUrl;
4294    if (!url) return;
4295
4296    // Aynı karta tekrar basmak duraklat/devam ettirir.
4297    if (_vnKart === kart && _vnSes && !_vnSes.paused) { _vnDurdur(); return; }
4298    _vnDurdur();
4299
4300    if (!_vnSes) {
4301      _vnSes = new Audio();
4302      _vnSes.addEventListener('timeupdate', () => {
4303        if (!_vnKart || !_vnSes.duration) return;
4304        const oran = Math.min(1, _vnSes.currentTime / _vnSes.duration);
4305        _vnKart.querySelector('.vn-fill').style.width = (oran * 100) + '%';
4306        _vnKart.querySelector('.vn-time').textContent = sesSuresi(_vnSes.currentTime * 1000);
4307      });
4308      _vnSes.addEventListener('ended', () => {
4309        if (_vnKart) {
4310          _vnKart.querySelector('.vn-fill').style.width = '0%';
4311          // Süre alanı bitişte ORİJİNALE dönsün: geçen süreyi gösterip
4312          // öylece kalırsa balon "0:00'lık bir kayıt" gibi görünüyordu.
4313          _vnKart.querySelector('.vn-time').textContent =
4314            sesSuresi(Number(_vnKart.dataset.voiceMs) || 0);
4315        }
4316        _vnDurdur();
4317      });
4318      _vnSes.addEventListener('error', () => {
4319        toast(t('chat.voice.error'), 'error');
4320        _vnDurdur();
4321      });
4322    }
4323    if (_vnSes.src !== new URL(url, location.href).href) _vnSes.src = url;
4324    _vnKart = kart;
4325    kart.querySelector('.vn-play').textContent = '⏸';
4326    kart.classList.add('playing');
4327    _vnSes.play().catch(() => {
4328      toast(t('chat.voice.error'), 'error');
4329      _vnDurdur();
4330    });
4331  });
4332}
4333
4334function _openImageLightbox(url, name) {
4335  if (!url) return;
4336  document.getElementById('attLightbox')?.remove();
4337  const ov = document.createElement('div');
4338  ov.id = 'attLightbox';
4339  ov.className = 'att-lb';
4340  ov.setAttribute('role', 'dialog');
4341  ov.setAttribute('aria-label', name);
4342  ov.innerHTML = `
4343    <div class="att-lb-bar">
4344      <span class="att-lb-name"></span>
4345      <span class="att-lb-actions">
4346        <a class="att-lb-btn" data-act="download" download title="${escapeHtml(t('panel.download'))}" aria-label="${escapeHtml(t('panel.download'))}">
4347          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12"/><path d="m7 12 5 5 5-5"/><path d="M5 21h14"/></svg>
4348        </a>
4349        <button type="button" class="att-lb-btn" data-act="close" title="${escapeHtml(t('chat.file.close_esc'))}" aria-label="${escapeHtml(t('action.close'))}">
4350          <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M18 6 6 18M6 6l12 12"/></svg>
4351        </button>
4352      </span>
4353    </div>
4354    <img class="att-lb-img" alt="">`;
4355  // src/href/alt DOM üzerinden yazılır (HTML birleştirmede kaçış hatası riski yok).
4356  ov.querySelector('.att-lb-name').textContent = name;
4357  const img = ov.querySelector('.att-lb-img');
4358  img.src = url;
4359  img.alt = name;
4360  const dl = ov.querySelector('[data-act="download"]');
4361  dl.href = url;
4362  dl.setAttribute('download', name);
4363
4364  const kapat = () => {
4365    ov.remove();
4366    document.removeEventListener('keydown', esc);
4367    document.body.style.overflow = _oncekiOverflow;
4368  };
4369  const esc = (ev) => { if (ev.key === 'Escape') kapat(); };
4370  ov.querySelector('[data-act="close"]').addEventListener('click', kapat);
4371  // Arka plana (görselin dışına) tıklayınca kapat; görsele/indir düğmesine
4372  // tıklamak kapatmasın.
4373  ov.addEventListener('click', (ev) => {
4374    if (ev.target === ov || ev.target.classList.contains('att-lb-stage')) kapat();
4375  });
4376  document.addEventListener('keydown', esc);
4377  const _oncekiOverflow = document.body.style.overflow;
4378  document.body.style.overflow = 'hidden';
4379  document.body.appendChild(ov);
4380}
4381
4382function _injectLightboxStyles() {
4383  if (document.getElementById('att-lightbox-styles')) return;
4384  const s = document.createElement('style');
4385  s.id = 'att-lightbox-styles';
4386  s.textContent = `
4387    .att-lightbox { border: 0; padding: 0; background: transparent; cursor: zoom-in;
4388      display: block; line-height: 0; }
4389    .att-lb { position: fixed; inset: 0; z-index: 9800; background: rgba(12,6,22,.88);
4390      display: flex; flex-direction: column; align-items: center; justify-content: center;
4391      padding: 56px 16px 24px; }
4392    .att-lb-bar { position: absolute; top: 0; left: 0; right: 0; height: 48px;
4393      display: flex; align-items: center; justify-content: space-between; gap: 12px;
4394      padding: 0 14px; color: #fff; background: linear-gradient(rgba(0,0,0,.45), transparent); }
4395    .att-lb-name { font-size: 13px; font-weight: 600; overflow: hidden;
4396      text-overflow: ellipsis; white-space: nowrap; }
4397    .att-lb-actions { display: flex; align-items: center; gap: 6px; flex: none; }
4398    .att-lb-btn { display: inline-flex; align-items: center; justify-content: center;
4399      width: 34px; height: 34px; border: 0; border-radius: 9px; cursor: pointer;
4400      background: rgba(255,255,255,.12); color: #fff; text-decoration: none; }
4401    .att-lb-btn:hover { background: rgba(255,255,255,.22); }
4402    .att-lb-img { max-width: min(94vw, 1400px); max-height: calc(100vh - 90px);
4403      object-fit: contain; border-radius: 10px; box-shadow: 0 24px 60px rgba(0,0,0,.5); }
4404
4405    /* Tek seferlik görüntüleme kartı (medya adresi elimizde YOK) */
4406    .ts-kart { display: flex; align-items: center; gap: 10px; width: 100%;
4407      padding: 11px 13px; border-radius: 12px; text-align: left; font: inherit;
4408      border: 1px dashed rgba(107,35,135,.42); background: rgba(107,35,135,.06);
4409      color: inherit; }
4410    button.ts-kart { cursor: pointer; }
4411    button.ts-kart:hover { background: rgba(107,35,135,.12); }
4412    .ts-bitti { border-style: solid; border-color: var(--border,#E6DEF0);
4413      background: transparent; opacity: .72; }
4414    .ts-ikon { flex: none; width: 30px; height: 30px; border-radius: 50%;
4415      display: inline-flex; align-items: center; justify-content: center;
4416      font-size: 15px; font-weight: 700; color: var(--brand, #6B2387);
4417      border: 1.5px solid rgba(107,35,135,.45); }
4418    .ts-bitti .ts-ikon { color: var(--text-secondary,#6B5F7E);
4419      border-color: var(--border,#E6DEF0); }
4420    .ts-metin { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
4421    .ts-ad { font-size: 13px; font-weight: 600; }
4422    .ts-not { font-size: 11px; opacity: .72; line-height: 1.35; }
4423    /* Gönderenin kendi tek seferlik görselindeki köşe rozeti */
4424    .attachment-card.image .att-lightbox { position: relative; }
4425    .ts-rozet { position: absolute; top: 7px; left: 7px; z-index: 2;
4426      width: 22px; height: 22px; border-radius: 50%; line-height: 20px;
4427      text-align: center; font-size: 12px; font-weight: 700; color: #fff;
4428      background: rgba(20,10,40,.62); border: 1px solid rgba(255,255,255,.55); }
4429  `;
4430  document.head.appendChild(s);
4431}
4432
4433function fileTypeIcon(mime = '') {
4434  if (mime.includes('pdf')) return '📕';
4435  if (mime.includes('word') || mime.includes('document')) return '📘';
4436  if (mime.includes('sheet') || mime.includes('excel')) return '📗';
4437  if (mime.includes('presentation') || mime.includes('powerpoint')) return '📙';
4438  if (mime.startsWith('text/')) return '📄';
4439  if (mime.startsWith('audio/')) return '🎵';
4440  if (mime.startsWith('video/')) return '🎬';
4441  return '📎';
4442}
4443
4444// =============== Emoji picker ===============
4445// Liste + açılır kutu emoji-picker.js'te (asistan soru-cevap eğitimi de aynı
4446// seçiciyi kullanıyor; iki kopya zamanla ayrışırdı).
4447
4448function wireEmojiButton(composer, sendBtn) {
4449  const btn = document.getElementById('emojiBtn');
4450  if (!btn) return;
4451  // Klonlayıp değiştiriyoruz: bu işlev sohbet her değiştiğinde yeniden
4452  // çağrılıyor, eski dinleyiciler üst üste binmesin.
4453  const fresh = btn.cloneNode(true);
4454  btn.parentNode.replaceChild(fresh, btn);
4455  emojiSeciciBagla(fresh, composer);
4456}
4457
4458async function ensureConversation() {
4459  if (state.conversationId) return state.conversationId;
4460  try {
4461    const c = await api.post('/api/conversations', { assistant_id: state.activeAssistantId });
4462    state.conversationId = c.id;
4463    upsertConversation(c).catch(() => {});
4464    return c.id;
4465  } catch (e) {
4466    toast(t('chat.start_failed', { error: e.message }), 'error');
4467    throw e;
4468  }
4469}
4470
4471async function realSend(composer, sendBtn, chatInner, hazirMetin) {
4472  // `hazirMetin`: öneri çipi / kurulumun ilk sorusu. Metin composer'dan
4473  // DEĞİL buradan gelir ve kullanıcının yazmakta olduğu taslak (ve seçili
4474  // alıntı) OLDUĞU GİBİ kalır — çipe dokunmak yarım mesajı silmemeli.
4475  const hazir = typeof hazirMetin === 'string';
4476  const text = (hazir ? hazirMetin : (composer.value || '')).trim();
4477  if (!text || state.inFlight) return;
4478  state.inFlight = true;
4479  sendBtn.disabled = true;
4480  // Kullanıcı konuştu: önceki cevabın önerileri artık geçmişe ait.
4481  _onerileriSondur(chatInner, null);
4482
4483  // 1. User bubble (instant — optimistic "şimdi" until server confirms)
4484  const userRow = document.createElement('div');
4485  userRow.className = 'msg-row self';
4486  userRow.innerHTML = `
4487    <div class="avatar tone-1"${_kartNitelik(window.morfozMe, window.morfozMe?.full_name)}>${_balonAvatarIci(window.morfozMe, (window.morfozMe?.full_name || 'DK').slice(0,2).toUpperCase())}</div>
4488    <div class="msg-stack"><div class="bubble"></div><div class="bubble-meta">${escapeHtml(t('chat.now'))}</div></div>
4489  `;
4490  userRow.querySelector('.bubble').innerHTML = vurgulaBahsetme(linkifyText(text));
4491  chatInner.appendChild(userRow);
4492  // After the server responds with the real user_message row, replace the
4493  // bubble-meta with the persisted timestamp so a refresh shows the same value
4494  // we just saw live (and not just "şimdi" forever).
4495  const optimisticMeta = userRow.querySelector('.bubble-meta');
4496  state.scrollToBottom && state.scrollToBottom();
4497
4498  // Alıntılı yanıt: id'yi İSTEK GÖNDERİLMEDEN ÖNCE oku ve şeridi hemen temizle.
4499  // Sonrasında okusaydık kullanıcı cevap beklerken yeni bir alıntı seçebilir ve
4500  // yanlış mesaj alıntılanmış olurdu.
4501  const replyToId = hazir ? null : pendingReplyId();
4502  if (!hazir) {
4503    clearReply();
4504    composer.value = '';
4505    composer.style.height = 'auto';
4506  }
4507
4508  // 2. Typing indicator (butterfly iframe)
4509  const typingRow = makeTypingIndicator();
4510  chatInner.appendChild(typingRow);
4511  state.scrollToBottom && state.scrollToBottom();
4512
4513  // 3. Real Claude call
4514  try {
4515    const convId = await ensureConversation();
4516    // UÇTAN UCA ŞİFRELİ SOHBET: metin ŞİFRELENMEDEN gönderilmez. Sunucu da
4517    // düz metni reddediyor (düşürme saldırısına kapalı) — yani burada bir
4518    // hata olursa mesaj sessizce açıkta gitmez, hiç gitmez.
4519    let zarf = null;
4520    if (state.e2e && state.e2e.enabled) {
4521      const mod = await import('./uctan-uca.js');
4522      zarf = await mod.sifrele(text, state.e2e.recipients || []);
4523      // ŞİFRELENEMEDİYSE DÜZ METNE DÜŞMÜYORUZ. Düşmek, kilit simgesi
4524      // dururken içeriği açıkta göndermek olurdu — sunucu da zaten reddeder,
4525      // ama asıl mesele kullanıcıya yalan söylememek.
4526      if (!zarf) {
4527        typingRow.remove();
4528        toast(t('e2e.encrypt_failed'), 'error');
4529        if (!hazir) composer.value = text;
4530        return;
4531      }
4532    }
4533    const r = await api.post('/api/conversations/messages', {
4534      conversation_id: convId,
4535      text: zarf ? '' : text,
4536      role: 'user',
4537      reply_to_id: replyToId,
4538      ...(zarf ? { envelope: zarf } : {}),
4539    });
4540    // SOHBET DEĞİŞTİ Mİ? (_eskiSayfayiCek ile aynı kapı.) Cevap beklenirken
4541    // kullanıcı sol listeden başka bir sohbete geçmiş olabilir; chatInner artık
4542    // ONU gösteriyor. Buraya çizmek A'nın cevabını B'nin altına koyar — ve
4543    // B'nin canlı onay kartını da "eski" sayıp kilitlerdi. Yalnız kimlikle
4544    // anahtarlanan yan etkiler kalıyor (önbellek + sol liste); DOM'a, composer'a
4545    // ve seenLiveIds'e (artık B'nin kümesi) dokunulmuyor. `finally` yine koşar.
4546    if (state.conversationId !== convId) {
4547      if (r.user_message) cacheAppendMessage(r.user_message);
4548      if (r.assistant_message) cacheAppendMessage(r.assistant_message);
4549      if (window.morfozBumpConversation) {
4550        window.morfozBumpConversation(convId, (r.assistant_message && r.assistant_message.text) || text);
4551      }
4552      return;
4553    }
4554    // 4. Remove typing, add bot bubble
4555    typingRow.remove();
4556    if (r.user_message) {
4557      cacheAppendMessage(r.user_message);
4558      // Cevap yazdın = konuşmayı kabul ettin: tanımadık kişi şeridi kalkar
4559      // (sunucu da bir sonraki açılışta göstermiyor).
4560      yabanciSeridiniKaldir();
4561      // DM/grup: kendi mesajım ws yayınında da gelir — id'yi işaretle ki
4562      // _onLiveMessage çift baloncuk çizmesin.
4563      if (!state.seenLiveIds) state.seenLiveIds = new Set();
4564      state.seenLiveIds.add(r.user_message.id);
4565      // Replace the optimistic "şimdi" with the server's persisted timestamp
4566      if (optimisticMeta && r.user_message.created_at) {
4567        optimisticMeta.textContent = formatChatTime(r.user_message.created_at);
4568      }
4569      // İyimser balonu gerçek mesaj id'siyle eşle: eylem menüsü, alıntı bloğu ve
4570      // canlı tepki güncellemeleri satırı ancak bu id ile bulabiliyor. Bunu
4571      // yapmazsak kullanıcı KENDİ yeni mesajına sayfa yenilenene kadar hiçbir
4572      // işlem uygulayamazdı.
4573      try { decorateRow(userRow, r.user_message); } catch {}
4574    }
4575    if (r.assistant_message && !(state.seenLiveIds && state.seenLiveIds.has(r.assistant_message.id))) {
4576      // ÇİFT BALON KORUMASI İKİ YÖNLÜ OLMAK ZORUNDA: sunucu cevabı önce
4577      // sokete basıp SONRA HTTP yanıtını döndürüyor, yani ws çerçevesi bu
4578      // koda ulaşmadan ÖNCE gelebiliyor. Asistan mesajları ws süzgecinden
4579      // geçmeye başladığı an, tek yönlü koruma aynı cevabı iki kez çizerdi.
4580      if (!state.seenLiveIds) state.seenLiveIds = new Set();
4581      state.seenLiveIds.add(r.assistant_message.id);
4582      _safeAppendBot(chatInner, r.assistant_message, 'chat-send');
4583      cacheAppendMessage(r.assistant_message);
4584      // Asistanın cevabı da yanıtlanabilir/yıldızlanabilir olmalı: id'yi hemen
4585      // iliştir, kullanıcı sayfa yenilemeyi beklemesin.
4586      try { decorateRow(chatInner.lastElementChild, r.assistant_message); } catch {}
4587    } else if (r.assistant_pending) {
4588      // Tur düştü ama müşteri cevapsız BIRAKILMADI: nöbetçi kurtarmayı
4589      // devraldı, cevap (ya da özür + tekrar deneme isteği) ws'ten düşecek.
4590      _beklemeNotu(chatInner, convId);
4591    }
4592    // Sol konuşma listesini canlı güncelle: önizleme = son mesaj, satır en üste.
4593    const lastText = (r.assistant_message && r.assistant_message.text) || text;
4594    if (window.morfozBumpConversation) window.morfozBumpConversation(convId, lastText);
4595    // GECİKMELİ sonuç (saniyeler sonra düşer): kullanıcı bu arada geçmişi
4596    // okumaya çıkmış olabilir. Dipteyse takip eder, değilse yerinden etmez —
4597    // bunun yerine "en alta in" düğmesinde okunmamış rozeti yanar.
4598    state.yeniMesajIsareti && state.yeniMesajIsareti();
4599    state.scrollToBottom && state.scrollToBottom(false);
4600  } catch (e) {
4601    reportClientError('chat-send', e, { conversation_id: state.conversationId });
4602    typingRow.remove();
4603    const errRow = document.createElement('div');
4604    errRow.className = 'msg-row';
4605    errRow.dataset.trainDecorated = '1';   // hata balonuna sahte "uyum" pili eklenmesin
4606    errRow.innerHTML = `
4607      <div class="avatar tone-4">!</div>
4608      <div class="msg-stack"><div class="bubble" style="background:rgba(184,56,56,0.08);color:var(--danger, #8E2A2A);border:1px solid rgba(184,56,56,0.2);">⚠ ${t('chat.send_error', { error: escapeHtml(e.message) })}</div></div>
4609    `;
4610    chatInner.appendChild(errRow);
4611    // GECİKMELİ sonuç (saniyeler sonra düşer): kullanıcı bu arada geçmişi
4612    // okumaya çıkmış olabilir. Dipteyse takip eder, değilse yerinden etmez —
4613    // bunun yerine "en alta in" düğmesinde okunmamış rozeti yanar.
4614    state.yeniMesajIsareti && state.yeniMesajIsareti();
4615    state.scrollToBottom && state.scrollToBottom(false);
4616  } finally {
4617    state.inFlight = false;
4618    sendBtn.disabled = composer.value.trim().length === 0;
4619    composer.focus();
4620  }
4621}
4622
4623/**
4624 * Hazır bir metni kullanıcı mesajı olarak gönder — öneri çipi ve kurulumun
4625 * "ilk soru" çipi bu kapıdan geçer. Composer'daki taslağa DOKUNMAZ.
4626 * Gönderim sürerken (`state.inFlight`) hiçbir şey yapmaz ve false döner.
4627 */
4628export function oneriGonder(metin) {
4629  const composer = document.querySelector('#chatView .composer-input') || document.querySelector('.composer-input');
4630  const sendBtn = document.querySelector('#chatView #sendBtn') || document.getElementById('sendBtn');
4631  const chatInner = document.querySelector('#chatView .chat-inner');
4632  const m = String(metin || '').trim();
4633  if (!composer || !sendBtn || !chatInner || !m || state.inFlight) return false;
4634  realSend(composer, sendBtn, chatInner, m);
4635  return true;
4636}
4637
4638// Decode the current user's id from the JWT (no extra API call) so we can tell
4639// whether they OWN the active assistant — owners get training controls, everyone
4640// else gets a ChatGPT-style copy / like / dislike bar.
4641function _jwtUserId() {
4642  try {
4643    const t = localStorage.getItem('morfoz_jwt');
4644    if (!t) return null;
4645    return String(JSON.parse(atob(t.split('.')[1])).sub || '') || null;
4646  } catch { return null; }
4647}
4648
4649// appendBotBubble render'ı çökerse: sunucu mesajı KAYDETTİ (HTTP 200) — bunu
4650// "gönderilemedi" hatası gibi göstermeyelim. Render hatasını admin "Hatalar"
4651// ekranına raporla ve mesajı düz metinle yine de göster (kullanıcı cevabı görür).
4652function _safeAppendBot(chatInner, msg, where) {
4653  try {
4654    appendBotBubble(chatInner, msg);
4655  } catch (renderErr) {
4656    try { reportClientError(where || 'appendBotBubble', renderErr, { message_id: msg && msg.id }); } catch {}
4657    const fb = document.createElement('div');
4658    fb.className = 'msg-row';
4659    fb.dataset.trainDecorated = '1';   // sahte uyum pili eklenmesin
4660    fb.innerHTML = `<div class="avatar tone-2">M</div><div class="msg-stack"><div class="bubble"></div><div class="bubble-meta">${formatChatTime(msg && msg.created_at)}</div></div>`;
4661    fb.querySelector('.bubble').textContent = (msg && msg.text) || '';
4662    // Yedek balon da ASİSTAN CEVABI: üstündeki eski onay kartını öldürmeli.
4663    _asistanSatiriEkle(chatInner, fb);
4664  }
4665}
4666
4667// ————————————————————————— ONREMO EKRANLI CEVAP —————————————————————————
4668//
4669// Onremo iş modülleri, asistan cevabına DOĞRULANMIŞ bir görünüm spec'i
4670// bindiriyor (sözleşme: Onremo/packages/module-sdk/src/asistan-gorunum.ts).
4671// Sunucu onu modelin bağlamından çıkarıp mesaja yazıyor; burada çiziyoruz.
4672//
4673// ⛔ ÇİZİM DEĞİL, SPEC. Gelen şey HTML değil bildirimsel bir tanım
4674// ({widget, rows, entity, text}); her yüzey KENDİ motoruyla çiziyor. Bu
4675// yüzden Onremo'nun HTML'ini basmıyoruz — basmak, karşı tarafın işaretlemesini
4676// panele enjekte etmek olurdu.
4677//
4678// ⛔ HER DEĞER KAÇIŞLA BASILIR. Satırlar müşterinin KENDİ kayıtlarından geliyor
4679// (ad, not, açıklama); içlerinde etiket olabilir. Sunucu YAPIYI doğruluyor,
4680// İÇERİĞİ değil — kaçış burada zorunlu.
4681//
4682// ⛔ ÇİZEMEDİĞİMİZ TÜR SESSİZ KALMAZ. Sözleşme her görünüme "text" alanını
4683// zorunlu koşuyor: tanımadığımız bir widget türü gelirse onu gösteriyoruz.
4684// Boş bir kutu çizmek, veriyi hiç göstermemekten beter olurdu.
4685
4686/** Alan değerini metne indir. null/undefined boş. */
4687function _ekranDeger(satir, ad) {
4688  if (!ad || !satir) return '';
4689  const v = satir[ad];
4690  return (v === null || v === undefined) ? '' : String(v);
4691}
4692
4693/** Alanın etiketi: entity şemasından, yoksa alan adının kendisi. Uydurma yok. */
4694function _ekranEtiket(gorunum, ad) {
4695  const f = (gorunum.entity && Array.isArray(gorunum.entity.fields))
4696    ? gorunum.entity.fields.find(x => x && x.name === ad) : null;
4697  return (f && f.label) ? f.label : ad;
4698}
4699
4700/** Geri düşüş: sözleşmenin zorunlu kıldığı metin. */
4701function _ekranMetinKutusu(gorunum) {
4702  const metin = String(gorunum.text || '').trim();
4703  if (!metin) return '';
4704  return `<div class="onr-ekran onr-ekran-metin">${escapeHtml(metin)}</div>`;
4705}
4706
4707function _ekranListe(w, rows, gorunum) {
4708  const bas = w.titleField;
4709  const altlar = Array.isArray(w.subtitleFields) ? w.subtitleFields : [];
4710  const rozet = w.badgeField;
4711  const meta = w.metaField;
4712  if (!rows.length) {
4713    const bos = String(w.emptyText || gorunum.text || '').trim();
4714    return `<div class="onr-ekran">
4715      ${w.title ? `<div class="onr-bas">${escapeHtml(w.title)}</div>` : ''}
4716      <div class="onr-bos">${escapeHtml(bos)}</div>
4717    </div>`;
4718  }
4719  const satirlar = rows.map(r => {
4720    const ustMetin = _ekranDeger(r, bas);
4721    const altMetin = altlar.map(a =>
4721 _ekranDeger(r, a)).filter(Boolean).join(' · ');
4722    const rozetMetin = _ekranDeger(r, rozet);
4723    const metaMetin = _ekranDeger(r, meta);
4724    return `<div class="onr-satir">
4725      <div class="onr-satir-ana">
4726        <div class="onr-satir-ust">${escapeHtml(ustMetin || '—')}</div>
4727        ${altMetin ? `<div class="onr-satir-alt">${escapeHtml(altMetin)}</div>` : ''}
4728      </div>
4729      ${rozetMetin ? `<span class="onr-rozet">${escapeHtml(rozetMetin)}</span>` : ''}
4730      ${metaMetin ? `<span class="onr-meta">${escapeHtml(metaMetin)}</span>` : ''}
4731    </div>`;
4732  }).join('');
4733  return `<div class="onr-ekran">
4734    ${w.title ? `<div class="onr-bas">${escapeHtml(w.title)}</div>` : ''}
4735    <div class="onr-liste">${satirlar}</div>
4736  </div>`;
4737}
4738
4739function _ekranKunye(w, rows, gorunum) {
4740  // "facts" widget'inde döküm satırları widget'in KENDİ rows'unda durur
4741  // ({label, from}); kaydın kendisi görünümün rows[0]'ıdır.
4742  const kayit = rows[0] || {};
4743  const alanlar = Array.isArray(w.rows) ? w.rows : [];
4744  const satirlar = alanlar.map(f => {
4745    const ad = f && f.from;
4746    const deger = _ekranDeger(kayit, ad);
4747    if (!deger) return '';
4748    const etiket = (f && f.label) ? f.label : _ekranEtiket(gorunum, ad);
4749    return `<div class="onr-kunye-satir">
4750      <span class="onr-kunye-etiket">${escapeHtml(etiket)}</span>
4751      <span class="onr-kunye-deger">${escapeHtml(deger)}</span>
4752    </div>`;
4753  }).filter(Boolean).join('');
4754  if (!satirlar) return _ekranMetinKutusu(gorunum);
4755  return `<div class="onr-ekran">
4756    ${w.title ? `<div class="onr-bas">${escapeHtml(w.title)}</div>` : ''}
4757    <div class="onr-kunye">${satirlar}</div>
4758  </div>`;
4759}
4760
4761/**
4762 * ONAY KARTI — Onremo aksiyon araçları kayıt güncelliyor/siliyor.
4763 *
4764 * ⛔ BU EKRANI SUNUCU ÜRETİR, MODEL DEĞİL (services/onremo_aksiyon.py). Model
4765 * onay metnini göstermeyi "unutabilir" ya da yumuşatabilir; kullanıcının neyi
4766 * onayladığı buna bırakılamaz. Kart, işlemin adını ve DEĞİŞECEK DEĞERLERİ
4767 * gösteriyor — "bir şeyi onayla" demek, "şu kaydı sil" demek değildir.
4768 *
4769 * ⛔ DÜĞMELER YALNIZ KOLAYLIK. Onayı veren şey düğme değil, kullanıcının
4770 * MESAJI: düğme composer'a "evet"/"hayır" yazıp normal gönderim yolunu
4771 * çalıştırıyor. Böylece onay her yüzeyde aynı biçimde oluşuyor (mobil ve
4772 * e-posta yazarak onaylıyor) ve panelde ayrı bir yetkili uç açılmıyor.
4773 */
4774function _ekranOnay(w, rows, gorunum) {
4775  const satirlar = (Array.isArray(rows) ? rows : []).map(r => {
4776    const etiket = (r && r.label) ? String(r.label) : '';
4777    const deger = (r && r.value !== undefined && r.value !== null) ? String(r.value) : '';
4778    if (!etiket && !deger) return '';
4779    return `<div class="onr-kunye-satir">
4780      <span class="onr-kunye-etiket">${escapeHtml(etiket)}</span>
4781      <span class="onr-kunye-deger">${escapeHtml(deger)}</span>
4782    </div>`;
4783  }).filter(Boolean).join('');
4784  const tehlike = !!w.danger;
4785  const baslik = String(w.title || '').trim();
4786  const govde = String(w.body || '').trim();
4787  return `<div class="onr-ekran onr-onay${tehlike ? ' onr-onay-tehlike' : ''}">
4788    <div class="onr-bas onr-onay-bas">${escapeHtml(t('onremo.confirm_needed'))}</div>
4789    <div class="onr-onay-govde">
4790      ${baslik ? `<div class="onr-onay-baslik">${escapeHtml(baslik)}</div>` : ''}
4791      ${govde ? `<div class="onr-onay-aciklama">${escapeHtml(govde)}</div>` : ''}
4792    </div>
4793    ${satirlar ? `<div class="onr-kunye">${satirlar}</div>` : ''}
4794    <div class="onr-onay-dugmeler">
4795      <button type="button" class="onr-onay-btn onr-onay-hayir"
4796        data-metin="${escapeHtml(t('onremo.reply_no'))}">${escapeHtml(t('action.cancel'))}</button>
4797      <button type="button" class="onr-onay-btn onr-onay-evet"
4798        data-metin="${escapeHtml(t('onremo.reply_yes'))}">${escapeHtml(t('action.confirm'))}</button>
4799    </div>
4800  </div>`;
4801}
4802
4803/**
4804 * ESKİ ONAY KARTI — kartın altına yeni bir asistan cevabı geldiyse kart ÖLÜDÜR.
4805 *
4806 * ⛔ NEDEN: sunucu bekleyen onayı, konuşmada başka bir iş olur olmaz kapatıyor
4807 * (services/modul_devam.py, "ESKİ ONAY SORUSU") ve 15 dakikada da düşürüyor.
4808 * Düğme ise yalnız composer'a "evet" yazıyor — HANGİ karta ait olduğunu
4809 * taşımıyor. Eski karttaki "Onayla", sunucuya son turun sorusuna ("sonraki
4810 * sayfa?") verilmiş düz bir "evet" olarak ulaşıyordu: kullanıcı bir işlemi
4811 * onayladığını sanıyor, alakasız bir cevap alıyor ve nedenini hiç görmüyordu.
4812 * Hangi kartın tıklandığını bilen tek yer burası, karar da burada.
4813 *
4814 * "Eski" = kartın satırından SONRA DOM'da başka bir asistan satırı var. Ölçüt
4815 * mesaj kimliği değil ekrandaki sıra: kullanıcının gördüğü sohbet bu.
4816 */
4817function _onrSonAsistanSatiri(kap) {
4818  if (!kap || !kap.querySelectorAll) return null;
4819  const satirlar = kap.querySelectorAll('[data-asistan]');
4820  return satirlar.length ? satirlar[satirlar.length - 1] : null;
4821}
4822
4823/** `son` verilmezse kartın kendi kabında aranır; toplu taramada bir kez bulunup geçilir. */
4824function _onrKartBayatMi(kart, son) {
4825  // Kartın satırı İŞARETLİ asistan satırı değilse (ya da hiç asistan satırı
4826  // yoksa) kart ESKİ SAYILMAZ: işaretlenmemiş bir çizim yolu yüzünden canlı
4827  // kartı kilitlemek, onayı hiç verilemez kılardı. Yanlış yöne düşen hata
4828  // eski davranıştır (düğme "evet" yazar), yeni bir kilit değil.
4829  const satir = kart && kart.closest ? kart.closest('[data-asistan]') : null;
4830  if (!satir) return false;
4831  if (son === undefined) son = _onrSonAsistanSatiri(satir.closest('.chat-inner') || satir.parentNode);
4832  return !!son && son !== satir;
4833}
4834
4835/**
4836 * Düğmelerin gönderdiği metinlerden biri, karttan sonraki İLK kullanıcı mesajı mı?
4837 *
4838 * ⛔ NEDEN VAR: onaylanıp YÜRÜTÜLMÜŞ bir kartın altına "süresi doldu, işlemi
4839 * yeniden isteyin" yazmak yalan olurdu — ve kullanıcıyı çoktan yapılmış bir
4840 * silmeyi/eklemeyi İKİNCİ KEZ istemeye yöneltirdi. Düğmenin gönderdiği metin
4841 * TAM DİZE olarak aranıyor (bkz. _ekranGovdeMetni): elle yazılmış "evet, sil"
4842 * gibi bir onay tanınmaz ve kart notu yine gösterir. Sunucunun onay sözlüğünü
4843 * burada kopyalamak iki ayrışan kopya demekti.
4844 *
4845 * Metinler SÖZLÜKLERİN HEPSİNDEN (tHepsi), karttaki data-metin'den DEĞİL: kart
4846 * o anki dilde çiziliyor. Kullanıcı "evet" deyip dili değiştirince yeniden
4847 * çizilen düğmede "yes" yazıyor ve yürütülmüş kart "süresi doldu" notu alıyordu.
4848 */
4849function _onrKartCevaplandiMi(kart) {
4850  const satir = kart.closest('[data-asistan]');
4851  const metinler = [...tHepsi('onremo.reply_yes'), ...tHepsi('onremo.reply_no')]
4852    .map(m => String(m).trim().toLocaleLowerCase('tr')).filter(Boolean);
4853  for (let s = satir && satir.nextElementSibling; s; s = s.nextElementSibling) {
4854    if (s.dataset && s.dataset.asistan) return false;       // sıradaki cevap geldi
4855    if (!s.classList || !s.classList.contains('msg-row') || !s.classList.contains('self')) continue;
4856    const balon = s.querySelector('.bubble');
4857    if (!balon) return false;
4858    // Alıntı bloğu ve "İletildi" etiketi balonun İÇİNE ekleniyor (decorateRow);
4859    // mesajın metni değiller.
4860    const metin = [...balon.childNodes]
4861      .filter(n => !(n.classList && (n.classList.contains('msg-quote') || n.classList.contains('msg-fwd'))))
4862      .map(n => n.textContent || '').join('').trim().toLocaleLowerCase('tr');
4863    return metinler.includes(metin);
4864  }
4865  return false;
4866}
4867
4868/** Kartı öldür: düğmeler kapanır, kart solar, gerekiyorsa not düşer. */
4869function _onrKartiBayatla(kart, { notZorla = false } = {}) {
4870  kart.classList.add('onr-onay-bayat');
4871  kart.querySelectorAll('.onr-onay-btn').forEach(b => { b.disabled = true; });
4872  if (kart.querySelector('.onr-onay-not')) return;
4873  if (!notZorla && _onrKartCevaplandiMi(kart)) return;
4874  const not = document.createElement('div');
4875  not.className = 'onr-onay-not';
4876  // Canlı bölge YALNIZ tıklama yolunda: kullanıcı o an bir şey yaptı, sonucu
4877  // duymalı. Geçmiş yüklenirken toplu işaretlenen N kart N kez okunmasın.
4878  if (notZorla) not.setAttribute('role', 'status');
4879  not.textContent = t('onremo.confirm_stale');
4880  kart.insertBefore(not, kart.querySelector('.onr-onay-dugmeler'));
4881}
4882
4883/**
4884 * Kaptaki eski kartları ÖNCEDEN işaretle — kullanıcı tıklamadan görsün.
4885 *
4886 * Ucuz: önce kart aranıyor; sohbetlerin neredeyse hiçbirinde kart yok, o
4887 * hâlde tek bir querySelectorAll ile dönülüyor. Kap bir DocumentFragment de
4888 * olabilir (yukarı kaydırmada eklenen eski sayfa); fragmanın son kartı,
4889 * sayfa DOM'a girince _eskiSayfayiCek'teki ikinci taramada işaretlenir.
4890 */
4891function _onrBayatlariIsaretle(kap) {
4892  if (!kap || !kap.querySelectorAll) return;
4893  const kartlar = [...kap.querySelectorAll('.onr-onay')]
4894    .filter(k => !k.classList.contains('onr-onay-bayat'));
4895  if (!kartlar.length) return;
4896  const son = _onrSonAsistanSatiri(kap);
4897  if (!son) return;
4898  kartlar.forEach(k => { if (_onrKartBayatMi(k, son)) _onrKartiBayatla(k); });
4899}
4900
4901/**
4902 * Asistan satırını kaba ekle — İŞARET + EKLEME + ESKİ KART TARAMASI tek yerde.
4903 *
4904 * ⛔ appendBotBubble'ın iki dalı (müşteri / sahip) ve render düşüş yedeği ayrı
4905 * ayrı ekliyordu; işareti birinde unutmak, o daldan gelen yeni cevabın eski
4906 * kartı ÖLDÜRMEMESİ demekti — hata da vermeden.
4907 */
4908function _asistanSatiriEkle(kap, satir) {
4909  satir.dataset.asistan = '1';
4910  kap.appendChild(satir);
4911  _onrBayatlariIsaretle(kap);
4912  // Öneri çipleri YALNIZ son asistan mesajında: yeni cevap eskilerini söndürür.
4913  _onerileriSondur(kap, satir);
4914}
4915
4916/** Onay düğmeleri — TEK delegasyon, balon başına dinleyici DEĞİL. */
4917function _onrOnayKancasi() {
4918  if (window.__onrOnayKanca) return;
4919  window.__onrOnayKanca = true;
4920  document.addEventListener('click', (e) => {
4921    const btn = e.target && e.target.closest && e.target.closest('.onr-onay-btn');
4922    if (!btn) return;
4923    const kart = btn.closest('.onr-onay');
4924    // ESKİ KART "evet" GÖNDERMEZ (bkz. _onrKartBayatMi). Composer'a hiç
4925    // dokunulmuyor: yazılan metin sunucuya en son turun cevabı olarak giderdi.
4926    if (kart && _onrKartBayatMi(kart)) {
4927      _onrKartiBayatla(kart, { notZorla: true });
4928      return;
4929    }
4930    // GÖNDERİM SÜRERKEN tıklama: composer'a yazılan "evet" realSend'in
4931    // `state.inFlight` kapısında hiç gitmez, kutuda KALIR ve bir sonraki turun
4932    // cevabı olarak giderdi. Düğmeler açık, composer dokunulmamış kalıyor:
4933    // cevap gelince ya kart yeniden tıklanır ya da yeni cevap onu eskitmiştir.
4934    if (state.inFlight) {
4935      if (window.morfozToast) window.morfozToast(t('onremo.confirm_busy'));
4936      return;
4937    }
4938    const composer = document.querySelector('.composer-input');
4939    const sendBtn = document.getElementById('sendBtn');
4940    if (!composer || !sendBtn) return;
4941    // Kart bir kez cevaplanır: ikinci tıklama ikinci bir "evet" göndermesin.
4942    if (kart) kart.querySelectorAll('.onr-onay-btn').forEach(b => { b.disabled = true; });
4943    composer.value = btn.dataset.metin || '';
4944    // `input` olayı ŞART: gönder düğmesinin disabled durumu ona bağlı
4945    // (initChatFlow'daki dinleyici). Atlanırsa düğme kapalı kalır ve tıklama
4946    // sessizce hiçbir şey yapmaz.
4947    composer.dispatchEvent(new Event('input', { bubbles: true }));
4948    sendBtn.click();
4949  });
4950}
4951
4952// ─────────── CEVAP ÖNERİLERİ + "ONREMO'DA AÇ" (sözleşme §3.8, §3.9) ───────────
4953//
4954// Sunucu asistan mesajının `view` üst düzeyine iki alan koyuyor:
4955//   oneriler: [{metin, kaynak: 'onremo'|'asistan'}]   dokununca gönderilir
4956//   kayit:    {modul_id, kayit_id, varlik, etiket, acilabilir}
4957// Onremo kaynaklı öneriler teslimde gelir; öbürleri cevaptan SONRA arka
4958// planda üretilir ve `message_suggestions` WS olayıyla düşer.
4959//
4960// ⛔ Çipler YALNIZ son asistan mesajının altında: yeni cevap (ya da
4961//    kullanıcının yazması) eskileri söndürür — konuşma ilerledikten sonra eski
4962//    bir öneriye dokunmak onu yanlış bağlamda gönderirdi.
4963// ⛔ Çip TASLAĞI EZMEZ (realSend `hazirMetin`) ve gönderim sürerken çalışmaz.
4964// ⛔ "Onremo'da aç" yalnız `message_id` gönderir: kayıt kimliği istemciden
4965//    ALINMAZ, sunucu mesajı veritabanından okuyup yetkiyi kendisi denetler.
4966
4967const _bekleyenOneriler = new Map();   // message_id → öneriler (satır henüz çizilmediyse)
4968const _DIS_BAGLANTI_IKONU = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>';
4969
4970function _cevapEkiStil() {
4971  if (document.getElementById('cvp-eki-style')) return;
4972  const s = document.createElement('style');
4973  s.id = 'cvp-eki-style';
4974  s.textContent = [
4975    '.cvp-oneriler{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;}',
4976    '.cvp-oneri{text-align:left;line-height:1.35;}',
4977    '.cvp-oneri:focus-visible,.cvp-kayit-btn:focus-visible{outline:2px solid var(--brand);outline-offset:2px;}',
4978    '.cvp-kayit{margin-top:6px;}',
4979    '.cvp-kayit-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:100px;',
4980    'border:1px solid var(--border-strong);background:var(--surface);color:var(--brand);',
4981    'font:inherit;font-size:12px;font-weight:600;cursor:pointer;}',
4982    '.cvp-kayit-btn:hover{background:var(--accent-soft);}',
4983    '.cvp-kayit-btn:disabled{opacity:.6;cursor:progress;}',
4984  ].join('');
4985  document.head.appendChild(s);
4986}
4987
4988function _sosyalSohbetMi() {
4989  return state.activeType === 'dm' || state.activeType === 'group';
4990}
4991
4992/** Ham öneri listesi → [{metin, kaynak}] (boş/tekrar elenir). */
4993function _oneriListesi(ham) {
4994  if (!Array.isArray(ham)) return [];
4995  const gorulen = new Set();
4996  const out = [];
4997  for (const o of ham) {
4998    const metin = String((o && typeof o === 'object') ? (o.metin || '') : (typeof o === 'string' ? o : '')).trim();
4999    const anahtar = metin.toLocaleLowerCase();
5000    if (!metin || gorulen.has(anahtar)) continue;
5001    gorulen.add(anahtar);
5002    out.push({ metin, kaynak: (o && o.kaynak) === 'onremo' ? 'onremo' : 'asistan' });
5003  }
5004  return out;
5005}
5006
5007function _onerilerHtml(liste) {
5008  if (!liste.length || _sosyalSohbetMi()) return '';
5009  _injectQaStripStyles();
5010  _cevapEkiStil();
5011  return '<div class="cvp-oneriler" role="group" aria-label="' + escapeHtml(t('chat.oneri.baslik')) + '">'
5012    + liste.map(o => '<button type="button" class="qa-pill cvp-oneri" data-cvp-oneri="' + escapeHtml(o.metin) + '"'
5013      + (o.kaynak === 'onremo' ? ' data-kaynak="onremo"' : '') + '>' + escapeHtml(o.metin) + '</button>').join('')
5014    + yardimDugmesiHtml('cevap_onerileri') + '</div>';
5015}
5016
5017function _kayitHtml(msg) {
5018  const k = msg && msg.view && msg.view.kayit;
5019  // `acilabilir` sunucunun kararı (ayar + Onremo'nun panelLink desteği);
5020  // istemci tahmin etmez. Müşteri turunda alan hiç gelmiyor.
5021  if (!k || typeof k !== 'object' || k.acilabilir !== true || msg.id == null || _sosyalSohbetMi()) return '';
5022  _cevapEkiStil();
5023  const etiket = String(k.etiket || '').trim();
5024  const ad = etiket ? t('chat.kayit.ac_etiketli', { etiket }) : t('chat.kayit.ac');
5025  return '<div class="cvp-kayit"><button type="button" class="cvp-kayit-btn" data-cvp-kayit="' + escapeHtml(String(msg.id))
5026    + '" aria-label="' + escapeHtml(ad) + '" title="' + escapeHtml(ad) + '">' + _DIS_BAGLANTI_IKONU
5027    + '<span>' + escapeHtml(t('chat.kayit.ac')) + '</span></button></div>';
5028}
5029
5030/** appendBotBubble'ın balon altı eki: öneri çipleri + "Onremo'da aç". */
5031function _cevapEkiHtml(msg) {
5032  if (!msg) return '';
5033  const w = msg.view && msg.view.widget;
5034  // Bekleyen onay ekranı varken öneri YOK: çip metni onayın cevabına karışmasın.
5035  const onayVar = !!(w && typeof w === 'object' && w.kind === 'confirm');
5036  const mid = Number(msg.id);
5037  const ham = (msg.view && msg.view.oneriler) || _bekleyenOneriler.get(mid);
5038  if (Number.isFinite(mid)) _bekleyenOneriler.delete(mid);
5039  return (onayVar ? '' : _onerilerHtml(_oneriListesi(ham))) + _kayitHtml(msg);
5040}
5041
5042/** Kapta `haric` dışındaki bütün öneri çiplerini söndür. */
5043function _onerileriSondur(kap, haric) {
5044  if (!kap || !kap.querySelectorAll) return;
5045  // Parça (DocumentFragment) = yukarı kaydırmada yüklenen ESKİ sayfa; altında
5046  // zaten daha yeni mesajlar var, yani içindeki hiçbir satır "son" değil.
5047  const tut = kap.nodeType === 11 ? null : haric;
5048  kap.querySelectorAll('.cvp-oneriler').forEach(el => { if (!tut || !tut.contains(el)) el.remove(); });
5049}
5050
5051/** Satır, kaptaki SON mesaj satırı olan bir asistan cevabı mı? */
5052function _sonAsistanSatiriMi(kap, satir) {
5053  if (!kap || !satir || satir.dataset.asistan !== '1' || !kap.contains(satir)) return false;
5054  for (let n = satir.nextElementSibling; n; n = n.nextElementSibling) {
5055    if (n.classList && n.classList.contains('msg-row') && !n.classList.contains('system-row')) return false;
5056  }
5057  return true;
5058}
5059
5060/** WS `message_suggestions` → o mesajın balonuna çipleri ekle. */
5061function _onerileriUygula(d) {
5062  if (!d || d.conversation_id == null || String(d.conversation_id) !== String(state.conversationId)) return;
5063  const mid = Number(d.message_id);
5064  if (!Number.isFinite(mid)) return;
5065  const liste = _oneriListesi(d.oneriler);
5066  const chatInner = document.querySelector('#chatView .chat-inner');
5067  const satir = chatInner && chatInner.querySelector('.msg-row[data-msg-id="' + mid + '"]');
5068  if (!satir) {
5069    // Olay balondan ÖNCE geldi (REST yanıtı yolda): balon çizilirken kullanılır.
5070    if (_bekleyenOneriler.size > 50) _bekleyenOneriler.clear();
5071    _bekleyenOneriler.set(mid, liste);
5072    return;
5073  }
5074  if (!_sonAsistanSatiriMi(chatInner, satir)) return;      // konuşma ilerlemiş
5075  satir.querySelector('.cvp-oneriler')?.remove();
5076  const html = _onerilerHtml(liste);
5077  const balon = satir.querySelector('.msg-stack > .bubble') || satir.querySelector('.bubble');
5078  if (!html || !balon) return;
5079  balon.insertAdjacentHTML('afterend', html);
5080  state.scrollToBottom && state.scrollToBottom(false);
5081}
5082
5083/** Çip dokunuşu: son cevabın önerisiyse gönder (gönderim sürerken hayır). */
5084function _oneriTikla(cip) {
5085  if (state.inFlight) {
5086    toast(t('chat.oneri.mesgul'));
5087    return;
5088  }
5089  const chatInner = document.querySelector('#chatView .chat-inner');
5090  const satir = cip.closest('.msg-row');
5091  if (satir && chatInner && !_sonAsistanSatiriMi(chatInner, satir)) {
5092    cip.closest('.cvp-oneriler')?.remove();
5093    return;
5094  }
5095  oneriGonder(cip.getAttribute('data-cvp-oneri') || '');
5096}
5097
5098/** "Onremo'da aç": tek kullanımlık panel bağlantısını iste, yeni sekmede aç. */
5099async function _kayitAc(btn) {
5100  const mid = Number(btn.getAttribute('data-cvp-kayit'));
5101  if (!Number.isFinite(mid) || btn.disabled) return;
5102  // Sekme TIKLAMA ANINDA açılıyor, adres sonra yazılıyor: bağlantı sunucudan
5103  // dönene kadar kullanıcı hareketi tükenir ve `await` sonrası window.open
5104  // açılır pencere engelleyicisine takılır (Safari). Opener HEMEN kesilir —
5105  // açılan Onremo sayfası bu pencereye erişemez (noopener ile aynı sonuç).
5106  let sekme = null;
5107  try {
5108    sekme = window.open('', '_blank');
5109    if (sekme) sekme.opener = null;
5110  } catch { sekme = null; }
5111  btn.disabled = true;
5112  btn.setAttribute('aria-busy', 'true');
5113  try {
5114    const r = await api.post('/api/onremo/kayit-baglantisi', { message_id: mid });
5115    const url = String((r && r.url) || '');
5116    if (!/^https:\/\//i.test(url)) throw Object.assign(new Error('gecersiz_baglanti'), { status: 502 });
5117    if (sekme && !sekme.closed) sekme.location.replace(url);
5118    else window.open(url, '_blank', 'noopener');
5119  } catch (e) {
5120    try { if (sekme) sekme.close(); } catch { /* sekme zaten kapalı */ }
5121    if (e && e.status === 409) {
5122      // Onremo bu kaydı panelde açmayı desteklemiyor: düğme bir daha çıkmasın.
5123      btn.closest('.cvp-kayit')?.remove();
5124      toast(t('chat.kayit.desteklenmiyor'));
5125    } else {
5126      toast(t('chat.kayit.acilamadi'), 'error');
5127    }
5128  } finally {
5129    btn.disabled = false;
5130    btn.removeAttribute('aria-busy');
5131  }
5132}
5133
5134/** Çip ve "Onremo'da aç" — TEK delegasyon, balon başına dinleyici DEĞİL. */
5135function _cevapEkiKancasi() {
5136  if (window.__cvpEkiKanca) return;
5137  window.__cvpEkiKanca = true;
5138  document.addEventListener('click', (e) => {
5139    const hedef = e.target && e.target.closest ? e.target : null;
5140    if (!hedef) return;
5141    const cip = hedef.closest('[data-cvp-oneri]');
5142    if (cip) { _oneriTikla(cip); return; }
5143    const kayit = hedef.closest('[data-cvp-kayit]');
5144    if (kayit) _kayitAc(kayit);
5145  });
5146}
5147
5148/**
5149 * Balonda gösterilecek METİN — onay bloğu kırpılmış hâliyle.
5150 *
5151 * Sunucu onay sorusunu mesajın GÖVDESİNE de yazıyor, çünkü mobil istemciler
5152 * `view` alanını hiç okumuyor ve orada onay ekranı çizilmiyor (ölçüldü:
5153 * Android Models.kt'de `view` yok). Panel kartı zaten çizdiği için aynı blok
5154 * burada bir kez daha görünmesin.
5155 *
5156 * ⛔ TAM DİZE EŞLEMESİ, desen DEĞİL. Eşleşme tutmazsa metin iki kez görünür —
5157 * çirkin ama GÖRÜNÜR. Gevşek bir desen ise kullanıcının kendi metninden bir
5158 * parçayı sessizce yutabilirdi.
5159 */
5160function _ekranGovdeMetni(msg) {
5161  const metin = String((msg && msg.text) || '');
5162  const w = msg && msg.view && msg.view.widget;
5163  if (!w || w.kind !== 'confirm') return metin;
5164  const blok = String((msg.view && msg.view.text) || '').trim();
5165  if (!blok) return metin;
5166  const kirpik = metin.trimEnd();
5167  if (!kirpik.endsWith(blok)) return metin;
5168  return kirpik.slice(0, kirpik.length - blok.length).trim();
5169}
5170
5171/** Mesajın taşıdığı Onremo ekranı — yoksa boş dize. */
5172function onremoEkranHtml(msg) {
5173  const gorunum = msg && msg.view;
5174  if (!gorunum || typeof gorunum !== 'object') return '';
5175  const w = (gorunum.widget && typeof gorunum.widget === 'object') ? gorunum.widget : null;
5176  if (!w) return '';
5177  const rows = Array.isArray(gorunum.rows) ? gorunum.rows : [];
5178  try {
5179    if (w.kind === 'list') return _ekranListe(w, rows, gorunum);
5180    if (w.kind === 'facts') return _ekranKunye(w, rows, gorunum);
5181    if (w.kind === 'confirm') return _ekranOnay(w, rows, gorunum);
5182    // Sözleşmede 11 sunum türü var; partner yüzeyi bugün yalnız bu ikisini
5183    // üretiyor (asistan-gorunum-uretici.ts). Ötekiler geldiğinde metne düşüyoruz
5184    // ki ekran hiç kaybolmasın; çizici eklemek ayrı ve küçük bir iş.
5185    return _ekranMetinKutusu(gorunum);
5186  } catch (e) {
5187    console.warn('[onremo] ekran çizilemedi', e);
5188    return _ekranMetinKutusu(gorunum);
5189  }
5190}
5191
5192function injectOnremoEkranStyles() {
5193  if (document.getElementById('onr-ekran-style')) return;
5194  const s = document.createElement('style');
5195  s.id = 'onr-ekran-style';
5196  s.textContent = [
5197    '.onr-ekran{margin-top:8px;border:1px solid var(--border,rgba(58,28,89,.14));',
5198    'border-radius:10px;overflow:hidden;background:var(--surface,#fff);}',
5199    '.onr-bas{padding:8px 11px;font-size:11.5px;font-weight:700;letter-spacing:.02em;',
5200    'color:var(--text-tertiary,#8A7E9B);text-transform:uppercase;',
5201    'border-bottom:1px solid var(--border,rgba(58,28,89,.10));}',
5202    '.onr-satir{display:flex;align-items:center;gap:8px;padding:8px 11px;',
5203    'border-bottom:1px solid var(--border,rgba(58,28,89,.07));}',
5204    '.onr-satir:last-child{border-bottom:0;}',
5205    '.onr-satir-ana{flex:1;min-width:0;}',
5206    '.onr-satir-ust{font-size:13px;font-weight:600;color:var(--text,#1F1430);',
5207    'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
5208    '.onr-satir-alt{font-size:11.5px;color:var(--text-tertiary,#8A7E9B);margin-top:1px;',
5209    'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
5210    '.onr-rozet{flex:none;font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:20px;',
5211    'background:var(--brand,#6B2387);color:#fff;white-space:nowrap;}',
5212    '.onr-meta{flex:none;font-size:11px;color:var(--text-tertiary,#8A7E9B);white-space:nowrap;}',
5213    '.onr-bos{padding:12px 11px;font-size:12.5px;color:var(--text-tertiary,#8A7E9B);}',
5214    '.onr-kunye-satir{display:flex;gap:10px;padding:7px 11px;',
5215    'border-bottom:1px solid var(--border,rgba(58,28,89,.07));font-size:12.5px;}',
5216    '.onr-kunye-satir:last-child{border-bottom:0;}',
5217    '.onr-kunye-etiket{flex:none;min-width:34%;color:var(--text-tertiary,#8A7E9B);}',
5218    '.onr-kunye-deger{flex:1;color:var(--text,#1F1430);font-weight:600;word-break:break-word;}',
5219    '.onr-ekran-metin{padding:10px 11px;font-size:12.5px;color:var(--text,#1F1430);',
5220    'white-space:pre-wrap;}',
5221    // ---- onay kartı ----
5222    // Renk TEK BAŞINA anlam taşımıyor: başlık şeridi ("Onay gerekiyor") ve
5223    // işlem adı metin olarak duruyor. Renk körü bir kullanıcı ya da yüksek
5224    // kontrast kipi kırmızıyı ayırt edemese de ne olacağını okuyabiliyor.
5225    // Açık temada `--warning`/`--danger` TANIMSIZ (yalnız dark-mode.js'de
5226    // var), o yüzden geri düşüş renkleri beyaz zeminde okunacak koyulukta
5227    // seçildi — açık sarı bir "Onay gerekiyor" şeridi görünmezdi.
5228    '.onr-onay{border-color:var(--warning,#9A6508);}',
5229    '.onr-onay-tehlike{border-color:var(--danger,#C0392B);}',
5230    '.onr-onay-bas{color:var(--warning,#9A6508);}',
5231    '.onr-onay-tehlike .onr-onay-bas{color:var(--danger,#C0392B);}',
5232    '.onr-onay-govde{padding:10px 11px;}',
5233    '.onr-onay-baslik{font-size:13.5px;font-weight:700;color:var(--text,#1F1430);}',
5234    '.onr-onay-aciklama{font-size:12.5px;color:var(--text-secondary,#5C5170);margin-top:3px;',
5235    'white-space:pre-wrap;}',
5236    '.onr-onay-dugmeler{display:flex;gap:8px;justify-content:flex-end;padding:9px 11px;',
5237    'border-top:1px solid var(--border,rgba(58,28,89,.10));}',
5238    '.onr-onay-btn{font:inherit;font-size:12.5px;font-weight:600;padding:6px 14px;',
5239    'border-radius:8px;cursor:pointer;border:1px solid var(--border,rgba(58,28,89,.18));',
5240    'background:var(--surface,#fff);color:var(--text,#1F1430);}',
5241    '.onr-onay-btn:disabled{opacity:.45;cursor:default;}',
5242    '.onr-onay-evet{background:var(--brand,#6B2387);border-color:var(--brand,#6B2387);color:#fff;}',
5243    '.onr-onay-tehlike .onr-onay-evet{background:var(--danger,#C0392B);',
5244    'border-color:var(--danger,#C0392B);}',
5245    // ⛔ KARANLIK MODDA METİN RENGİ DÖNER. Gece paletinde --danger AÇIK bir
5246    // kırmızı (#FF6B6B); üstündeki beyaz yazı ~2.3:1 kalıyordu, yani en
5247    // yıkıcı işlemin düğmesi tam da okunması gereken yerde okunmuyordu.
5248    ':root[data-theme="dark"] .onr-onay-tehlike .onr-onay-evet{color:#2A0710;}',
5249    // ---- eski (süresi dolmuş) kart ----
5250    // Solukluk YALNIZ çerçevede ve başlıkta: gövde okunur kalıyor, kullanıcı
5251    // hangi işlemin düştüğünü görebilmeli. Not metin olarak duruyor.
5252    '.onr-onay-bayat,.onr-onay-bayat.onr-onay-tehlike{border-color:var(--border,rgba(58,28,89,.14));}',
5253    '.onr-onay-bayat .onr-onay-bas{color:var(--text-tertiary,#8A7E9B);}',
5254    '.onr-onay-not{padding:8px 11px 0;font-size:12px;font-weight:600;',
5255    'color:var(--text-secondary,#5C5170);}',
5256  ].join('');
5257  document.head.appendChild(s);
5258  _onrOnayKancasi();
5259}
5260
5261function appendBotBubble(chatInner, msg) {
5262  const isOwner = !!state.activeIsOwner;
5263  const row = document.createElement('div');
5264  row.className = 'msg-row';
5265  // Mark as already-decorated so the prototype's inline-training MutationObserver
5266  // skips this row — we render the footer ourselves below.
5267  row.dataset.trainDecorated = '1';
5268  // ONREMO EKRANI balonun İÇİNE, metnin ALTINA giriyor. Tek yerden
5269  // ekleniyor çünkü aşağıdaki iki dal (müşteri görünümü / sahip görünümü)
5270  // aynı `html` değişkenini basıyor; ayrı ayrı eklemek birini unutmaya
5271  // açıktı ve o dalda ekran sessizce kaybolurdu.
5272  injectOnremoEkranStyles();
5273  const html = renderClaudeText(_ekranGovdeMetni(msg)) + onremoEkranHtml(msg);
5274  // Öneri çipleri + "Onremo'da aç" BALONUN DIŞINDA, hemen altında: balonun
5275  // içine girseydi "Kopyala" çip metinlerini de kopyalar, ideal cevap
5276  // kaydı da balonu yeniden yazarken onları silerdi. İki dal aynı `ek`i basar.
5277  const ek = _cevapEkiHtml(msg);
5278  const ts = formatChatTime(msg.created_at) + _teslimIsareti(msg);
5279
5280  if (!isOwner) {
5281    // ---- CUSTOMER view: ChatGPT-style feedback (copy / like / dislike). No
5282    //      training UI, no conformity ("uyum") — those belong to the owner. ----
5283    row.innerHTML = `
5284      <div class="avatar tone-2">M</div>
5285      <div class="msg-stack">
5286        <div class="bubble">${html}</div>${ek}
5287        <div class="bubble-meta">${ts}</div>
5288        <div class="msg-fb" style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
5289          <button data-fb="copy" title="${escapeHtml(t('chat.fb.copy_title'))}" type="button" style="display:inline-flex;align-items:center;gap:5px;padding:4px 9px;border:1px solid var(--border,rgba(58,28,89,.12));background:transparent;color:var(--text-tertiary,#8A7E9B);border-radius:14px;cursor:pointer;font-family:inherit;font-size:11px;font-weight:600;">
5290            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
5291            ${escapeHtml(t('panel.copy'))}
5292          </button>
5293          <button data-fb="up" title="${escapeHtml(t('chat.fb.up_title'))}" type="button" style="display:inline-flex;align-items:center;gap:5px;padding:4px 9px;border:1px solid var(--border,rgba(58,28,89,.12));background:transparent;color:var(--text-tertiary,#8A7E9B);border-radius:14px;cursor:pointer;font-family:inherit;font-size:11px;font-weight:600;">
5294            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7"/></svg>
5295            ${escapeHtml(t('chat.fb.up'))}
5296          </button>
5297          <button data-fb="down" title="${escapeHtml(t('chat.fb.down_title'))}" type="button" style="display:inline-flex;align-items:center;gap:5px;padding:4px 9px;border:1px solid var(--border,rgba(58,28,89,.12));background:transparent;color:var(--text-tertiary,#8A7E9B);border-radius:14px;cursor:pointer;font-family:inherit;font-size:11px;font-weight:600;">
5298            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17"/></svg>
5299            ${escapeHtml(t('chat.fb.down'))}
5300          </button>
5301        </div>
5302      </div>
5303    `;
5304    _asistanSatiriEkle(chatInner, row);
5305    const fb = row.querySelector('.msg-fb');
5306    fb.querySelector('[data-fb="copy"]').addEventListener('click', async () => {
5307      // Ekranda ne görünüyorsa o kopyalansın — ham <strong>/**…** işaretleri değil.
5308      const shown = row.querySelector('.bubble');
5309      const plain = (shown && shown.innerText ? shown.innerText : (msg.text || '')).trim();
5310      try { await navigator.clipboard.writeText(plain); window.morfozToast && window.morfozToast(t('chat.copied')); }
5311      catch { window.morfozToast && window.morfozToast(t('chat.act.copy_failed'), 'error'); }
5312    });
5313    fb.querySelectorAll('[data-fb="up"],[data-fb="down"]').forEach(b => {
5314      b.addEventListener('click', () => {
5315        const on = b.classList.contains('on');
5316        fb.querySelectorAll('[data-fb="up"],[data-fb="down"]').forEach(x => {
5317          x.classList.remove('on'); x.style.color = 'var(--text-tertiary,#A89DB5)';
5318        });
5319        if (!on) { b.classList.add('on'); b.style.color = 'var(--brand,#6B2387)'; }
5320        markMessage(msg.id, on ? null : (b.dataset.fb === 'up' ? 'up' : 'down'));
5321        if (!on && window.morfozToast) window.morfozToast(b.dataset.fb === 'up' ? t('chat.fb.thanks') : t('chat.fb.received'));
5322      });
5323    });
5324    return;
5325  }
5326
5327  // ---- OWNER view: training controls (conformity "uyum" + up/down + ideal answer) ----
5328  const score = msg.conformity_score;
5329  let scoreClass = 'low';
5330  if (score >= 80) scoreClass = 'high';
5331  else if (score >= 60) scoreClass = 'mid';
5332  const scoreLabel = score >= 80 ? t('chat.train.score_high')
5333    : (score >= 60 ? t('chat.train.score_mid') : t('chat.train.score_low'));
5334
5335  row.innerHTML = `
5336    <div class="avatar tone-2">M</div>
5337    <div class="msg-stack">
5338      <div class="bubble">${html}</div>${ek}
5339      <div class="bubble-meta">${ts}</div>
5340      <div class="train-row">
5341        ${score != null ? `<span class="conformity-pill ${scoreClass}" title="${escapeHtml(t('chat.train.score_title'))}">${score}<span class="label">% ${escapeHtml(scoreLabel)}</span></span>` : ''}
5342        <div class="train-actions">
5343          <button class="train-act-btn up"   data-act="up"   title="${escapeHtml(t('chat.train.good'))}" type="button">
5344            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7"/></svg>
5345          </button>
5346          <button class="train-act-btn down" data-act="down" title="${escapeHtml(t('chat.train.bad'))}" type="button">
5347            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17"/></svg>
5348          </button>
5349          <button class="train-act-btn edit" data-act="edit" type="button">
5350            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="m18.5 2.5 3 3L12 15l-4 1 1-4z"/></svg>
5351            ${escapeHtml(t('chat.train.write_ideal'))}
5352          </button>
5353        </div>
5354      </div>
5355      <div class="ideal-composer">
5356        <div class="ideal-composer-head">
5357          <span class="ideal-composer-title">
5358            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="m18.5 2.5 3 3L12 15l-4 1 1-4z"/></svg>
5359            ${escapeHtml(t('chat.train.ideal_header'))}
5360          </span>
5361        </div>
5362        <textarea>${escapeHtml(msg.text || '')}</textarea>
5363        <div class="ideal-bicim" style="display:flex;gap:5px;align-items:center;margin-top:6px;flex-wrap:wrap;"></div>
5364        <div class="ideal-composer-foot">
5365          <span>${escapeHtml(t('chat.train.ideal_hint'))}</span>
5366          <div>
5367            <button class="btn-cancel" type="button">${escapeHtml(t('action.cancel'))}</button>
5368            <button class="btn-save" type="button">
5369              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M5 12l4 4L19 7"/></svg>
5370              ${escapeHtml(t('panel.add_to_training'))}
5371            </button>
5372          </div>
5373        </div>
5374      </div>
5375    </div>
5376  `;
5377  _asistanSatiriEkle(chatInner, row);
5378
5379  // Wire up the per-row train actions (mirrors prototype's inline-training but uses real msg.id)
5380  const trainRow = row.querySelector('.train-row');
5381  const ideal = row.querySelector('.ideal-composer');
5382  trainRow.querySelectorAll('.train-act-btn').forEach(btn => {
5383    btn.addEventListener('click', (e) => {
5384      e.stopPropagation();
5385      const act = btn.dataset.act;
5386      if (act === 'up') {
5387        trainRow.querySelectorAll('.train-act-btn').forEach(b => b.classList.remove('marked'));
5388        btn.classList.add('marked');
5389        markMessage(msg.id, 'up');
5390        if (window.morfozToast) window.morfozToast(t('chat.train.marked_good'), 'success');
5391      } else if (act === 'down') {
5392        trainRow.querySelectorAll('.train-act-btn').forEach(b => b.classList.remove('marked'));
5393        btn.classList.add('marked');
5394        markMessage(msg.id, 'down');
5395        if (window.morfozToast) window.morfozToast(t('chat.train.marked_bad'));
5396      } else if (act === 'edit') {
5397        ideal.classList.add('active');
5398        const ta = ideal.querySelector('textarea');
5399        // Biçim çubuğu AÇILINCA kurulur: satır çizildiği anda kurmak, hiç
5400        // "düzenle" denmeyen yüzlerce mesaj için boşuna DOM işi olurdu.
5401        import('./bicim-cubugu.js')
5402          .then(({ bicimCubuguBagla }) => bicimCubuguBagla(ideal.querySelector('.ideal-bicim'), ta))
5403          .catch(() => { /* çubuk yüklenemedi — düz metin yazımı çalışmaya devam eder */ });
5404        ta.focus();
5405        ta.setSelectionRange(ta.value.length, ta.value.length);
5406      }
5407    });
5408  });
5409  ideal.querySelector('.btn-cancel').addEventListener('click', () => ideal.classList.remove('active'));
5410  ideal.querySelector('.btn-save').addEventListener('click', async () => {
5411    const newText = ideal.querySelector('textarea').value.trim();
5412    if (!newText) return;
5413    // Find the originating user question (the previous self bubble)
5414    const prevUser = row.previousElementSibling;
5415    const userText = prevUser?.querySelector('.bubble')?.textContent?.trim() || t('chat.train.prev_user_msg');
5416    try {
5417      await api.post('/api/files/examples', {
5418        assistant_id: state.activeAssistantId,
5419        user_question: userText,
5420        ideal_answer: newText,
5421        source_message_id: msg.id,
5422        scope: 'public',
5423      });
5424      row.querySelector('.bubble').innerHTML = renderClaudeText(newText);
5425      ideal.classList.remove('active');
5426      const pill = trainRow.querySelector('.conformity-pill');
5427      if (pill) {
5428        pill.className = 'conformity-pill high';
5429        pill.innerHTML = `100<span class="label">% ${escapeHtml(t('chat.train.trained'))}</span>`;
5430      }
5431      if (window.morfozToast) window.morfozToast(t('chat.train.updated'), 'success');
5432    } catch (e) {
5433      if (window.morfozToast) window.morfozToast(e.message, 'error');
5434    }
5435  });
5436}
5437
5438async function markMessage(messageId, mark) {
5439  try {
5440    await api.post('/api/conversations/messages/mark', { message_id: messageId, mark });
5441  } catch (e) { /* silent */ }
5442}
5443
5444// Modelin ürettiği metinde HTML kaçabiliyor (<strong>, <a href="…">, <br>).
5445// Bunlar escape edilince kullanıcıya HAM ETİKET olarak görünüyordu. Çözüm:
5446// etiket varsa metni HTML sayıp izin listesiyle TEMİZLEYİP basmak (ham etiket
5447// yerine gerçek biçim), yoksa eski güvenli markdown yolundan gitmek.
5448// Not: model çıktısı kullanıcı girdisinden/RAG'den etkilenebildiği için
5449// ham innerHTML ASLA kullanılmaz — allowlist dışındaki her şey ayıklanır.
5450const RICH_ALLOWED = {
5451  A: ['href', 'title'], B: [], STRONG: [], I: [], EM: [], U: [], S: [], DEL: [],
5452  BR: [], P: [], DIV: [], SPAN: [], UL: [], OL: [], LI: [], CODE: [], PRE: [],
5453  BLOCKQUOTE: [], H1: [], H2: [], H3: [], H4: [], H5: [], H6: [], HR: [],
5454  SMALL: [], SUP: [], SUB: [], TABLE: [], THEAD: [], TBODY: [], TR: [], TH: [], TD: [],
5455};
5456const RICH_TAG_RE = /<\/?(?:a|b|strong|i|em|u|s|del|br|p|div|span|ul|ol|li|code|pre|blockquote|h[1-6]|hr|small|sup|sub|table|thead|tbody|tr|th|td)\b[^>]*>/i;
5457const RICH_BLOCK_RE = /<(?:p|div|ul|ol|li|br|h[1-6]|table|pre|blockquote)\b/i;
5458
5459function sanitizeRichHtml(raw) {
5460  const tpl = document.createElement('template');
5461  tpl.innerHTML = String(raw || '');              // <template> içeriği inert: script/img onerror ÇALIŞMAZ
5462  tpl.content.querySelectorAll('script,style,iframe,object,embed,form,input,button,link,meta,svg')
5463    .forEach(el => el.remove());
5464  // İzin listesi dışındaki etiketleri kaldır ama İÇERİĞİNİ koru (unwrap).
5465  for (let guard = 0; guard < 500; guard++) {
5466    const bad = Array.from(tpl.content.querySelectorAll('*'))
5467      .find(el => !Object.prototype.hasOwnProperty.call(RICH_ALLOWED, el.tagName));
5468    if (!bad) break;
5469    bad.replaceWith(...bad.childNodes);
5470  }
5471  tpl.content.querySelectorAll('*').forEach(el => {
5472    const ok = RICH_ALLOWED[el.tagName] || [];
5473    Array.from(el.attributes).forEach(at => {
5474      const n = at.name.toLowerCase();
5475      // on* olay öznitelikleri, style, srcdoc… hepsi listede olmadığı için düşer.
5476      if (!ok.includes(n)) { el.removeAttribute(at.name); return; }
5477      if (n === 'href' && !/^(https?:|mailto:|tel:)/i.test(String(at.value).trim())) {
5478        el.removeAttribute(at.name);
5479      }
5480    });
5481    if (el.tagName === 'A') {
5482      if (el.getAttribute('href')) {
5483        el.setAttribute('target', '_blank');
5484        el.setAttribute('rel', 'noopener noreferrer nofollow');
5485      } else {
5486        el.replaceWith(...el.childNodes);          // hedefsiz link = düz metin
5487      }
5488    }
5489  });
5490  return tpl.innerHTML;
5491}
5492
5493/**
5494 * @bahsetme vurgusu — ZATEN HTML üretilmiş bir dizge üzerinde.
5495 *
5496 * Hem `_mdInline` (asistan cevapları, markdown'lı) hem `linkifyText` (insan
5497 * mesajları, düz metin) çıkışına uygulanıyor. Tek yerde durmasının sebebi
5498 * şuydu: yalnız `_mdInline`e koyunca vurgu SADECE klon/asistan cevaplarında
5499 * çıkıyor, insanların yazdığı mesajlarda hiç çıkmıyordu — oysa etiketlemeyi
5500 * asıl insanlar yapıyor. Mobilde kalın, panelde düz görünüyordu.
5501 *
5502 * Önceki karakter sınıfı `/` ve `:` içeriyor: `https://youtube.com/@morfozai`
5503 * gibi bağlantıların içine girip href'i bozmasın.
5504 */
5505function vurgulaBahsetme(html) {
5506  return String(html).replace(
5507    /(^|[^\w@>\/:])@([A-Za-z0-9_][A-Za-z0-9_.]{1,31})\b(\.?)/g,
5508    (tam, onceki, ad, nokta) =>
5509      `${onceki}<span class="mz-bahset">@${ad}</span>${nokta}`);
5510}
5511
5512// Kaçmış markdown işaretleri (**, *, `, [x](y), ###) — GİRDİ ZATEN ESCAPE'Lİ.
5513function _mdInline(esc) {
5514  let h = esc;
5515  h = h.replace(/(^|\n)#{1,6}\s+(.+)/g, '$1<strong>$2</strong>');
5516  h = h.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
5517    '<a href="$2" target="_blank" rel="noopener noreferrer nofollow">$1</a>');
5518  h = h.replace(/\*\*([^\*]+?)\*\*/g, '<strong>$1</strong>');
5519  // Geriye-bakış (?<! KULLANMA: Safari 16.4'ten eski sürümlerde regex DERLENMEZ
5520  // ve SyntaxError modülün tamamını düşürür (iOS 15/16.0-16.3 = beyaz ekran).
5521  // Önceki karakteri yakalayıp geri yazmak aynı işi görür.
5522  h = h.replace(/(^|[^*])\*([^*\n]+?)\*(?!\*)/g, '$1<em>$2</em>');
5523  h = h.replace(/`([^`\n]+?)`/g, '<code style="background:rgba(107,35,135,0.08);padding:1px 5px;border-radius:3px;font-family:var(--mono);font-size:0.92em;">$1</code>');
5524  // @BAHSETME vurgusu. Bağlantılardan SONRA, linkify'dan ÖNCE: e-posta
5525  // adresindeki @ ([email protected]) etiket sayılmamalı, o yüzden desen
5526  // sunucudaki `services/bahsetme._ETIKET` ile birebir aynı — önündeki
5527  // karakter harf/@ ise eşleşmiyor ve sondaki nokta dışarıda kalıyor.
5528  // Geriye-bakış (?<!) KULLANILMIYOR: eski Safari'de regex derlenmez ve
5529  // modülün tamamı düşer (yukarıdaki nota bakınız).
5530  h = vurgulaBahsetme(h);
5531  // Çıplak URL'ler de tıklanabilir olsun. EN SONDA: linkifyHtml mevcut
5532  // <a>/<code> parçalarını atlar, yani üstteki [metin](url) bağlantıları
5533  // bozulmaz ve kod içindeki URL ham kalır.
5534  return linkifyHtml(h);
5535}
5536
5537// HTML + markdown KARIŞIK cevaplarda (`**Fiyat:** <br> …`) markdown işaretleri
5538// ekranda kalmasın: yalnız METİN düğümlerine uygula, etiket/öznitelik içine girme.
5539function _mdInsideHtml(safeHtml) {
5540  const tpl = document.createElement('template');
5541  tpl.innerHTML = safeHtml;
5542  const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_TEXT);
5543  const nodes = [];
5544  while (walker.nextNode()) nodes.push(walker.currentNode);
5545  nodes.forEach(n => {
5546    const p = n.parentElement;
5547    if (p && (p.tagName === 'CODE' || p.tagName === 'PRE' || p.tagName === 'A')) return;
5548    const esc = escapeHtml(n.nodeValue || '');
5549    const out = _mdInline(esc);
5550    if (out === esc) return;
5551    const frag = document.createElement('template');
5552    frag.innerHTML = out;                          // kaynak kendi regex'imiz → güvenli
5553    n.replaceWith(frag.content);
5554  });
5555  return tpl.innerHTML;
5556}
5557
5558// Konum bağlantısını görünür bir "harita" etiketine çevirir. Asistan bir yer
5559// listelerken araç sonucundaki hazır bağlantıyı basar; kullanıcı düz mavi bir
5560// URL yerine 📍 rozeti görür ve dokununca harita açılır.
5561// Bağlantı bu noktada ZATEN sanitize edilmiştir (href protokolü doğrulandı);
5562// burada yalnız SUNUM değişir, yeni bir hedef üretilmez.
5563const HARITA_HOST_RE =
5564  /^https?:\/\/(?:www\.)?(?:maps\.google\.[a-z.]+|google\.[a-z.]+\/maps|maps\.apple\.com|(?:www\.)?openstreetmap\.org)/i;
5565const HARITA_STIL = 'display:inline-block;background:rgba(107,35,135,.10);color:var(--text-primary, #4C3A78);'
5566  + 'border:1px solid rgba(107,35,135,.20);border-radius:20px;padding:1px 9px;'
5567  + 'font-size:.92em;font-weight:600;text-decoration:none;white-space:nowrap;';
5568
5569function _haritaEtiketle(html) {
5570  if (!/maps\.|\/maps/i.test(html)) return html;     // ucuz eleme
5571  const tpl = document.createElement('template');
5572  tpl.innerHTML = html;
5573  tpl.content.querySelectorAll('a[href]').forEach(a => {
5574    if (!HARITA_HOST_RE.test(a.getAttribute('href') || '')) return;
5575    const metin = (a.textContent || '').trim();
5576    a.textContent = /📍/.test(metin) ? metin : `📍 ${metin || t('chat.map.open')}`;
5577    a.setAttribute('style', HARITA_STIL);
5578    a.setAttribute('target', '_blank');
5579    a.setAttribute('rel', 'noopener noreferrer nofollow');
5580  });
5581  return tpl.innerHTML;
5582}
5583
5584// ---------------------------------------------------------------- md tablo
5585//
5586// NEDEN VAR: model tabloyu markdown boru biçiminde yazıyor
5587// (`| Firma | Telefon |` + `|---|---|`). Çizici bunu tanımıyordu; kullanıcı
5588// balonda ham boruları ve `|---|---|` ayraç satırını okuyordu. Canlı örnek:
5589// Salesforce'tan 14 Account kaydı — beş sütunluk düzgün bir tablo, ekranda
5590// okunamaz bir metin yığını. Kusur Salesforce'ta değil, ÇİZİCİDEYDİ; aynı
5591// hata SAP'ta ve düz sohbette de vardı.
5592//
5593// SATIR SATIR TARANIR, tek büyük düzenli ifadeyle değil: tablo bloğu üç
5594// parçalı (başlık + ayraç + gövde) ve gövde satır sayısı belirsiz. Tek regex
5595// okunabilirliği kaybettirir ve hatalı eşleşmede sessizce yanlış tablo çizer.
5596//
5597// ⛔ ÇİFTE KAÇIŞ YOK: bu fonksiyon `escapeHtml` + `_mdInline` UYGULANMIŞ metin
5598// alır. Hücre içeriği zaten güvenli; yeniden kaçırmak `&amp;` üretirdi.
5599//
5600// ⛔ YER TUTUCU KULLANILIR: tablo HTML'i hemen gömülseydi, aşağıdaki
5601// `\n` → `<br>` dönüşümü tablonun içine satır sonu serpiştirirdi. Blok
5602// önce çıkarılır, dönüşümler bittikten sonra geri konur.
5603const _TABLO_AYRAC_RE = /^\|?[\s:|-]*-[\s:|-]*\|?$/;
5604
5605/** `| a | b |` → ['a','b'] (baştaki/sondaki boş hücre atılır). */
5606function _tabloHucreleri(satir) {
5607  let ham = satir.trim();
5608  if (ham.startsWith('|')) ham = ham.slice(1);
5609  if (ham.endsWith('|')) ham = ham.slice(0, -1);
5610  return ham.split('|').map(h => h.trim());
5611}
5612
5613/** Ayraç satırından hizalama: `:--` sol, `--:` sağ, `:-:` orta. */
5614function _tabloHizalar(satir) {
5615  return _tabloHucreleri(satir).map(h => {
5616    const sol = h.startsWith(':');
5617    const sag = h.endsWith(':');
5618    if (sol && sag) return 'center';
5619    if (sag) return 'right';
5620    return 'left';
5621  });
5622}
5623
5624function _tabloBloguHtml(satirlar) {
5625  const basliklar = _tabloHucreleri(satirlar[0]);
5626  const hizalar = _tabloHizalar(satirlar[1]);
5627  const hiza = i => hizalar[i] || 'left';
5628  const govde = satirlar.slice(2);
5629  const th = basliklar.map((b, i) =>
5630    `<th style="text-align:${hiza(i)};">${b}</th>`).join('');
5631  // Sütun sayısı başlıktan gelir: fazla hücre atılır, eksik hücre boş basılır.
5632  // Aksi hâlde bozuk bir satır tüm tabloyu kaydırırdı.
5633  const tr = govde.map(satir => {
5634    const h = _tabloHucreleri(satir);
5635    const td = basliklar.map((_, i) =>
5636      `<td style="text-align:${hiza(i)};">${h[i] === undefined ? '' : h[i]}</td>`).join('');
5637    return `<tr>${td}</tr>`;
5638  }).join('');
5639  // Dış kaydırma: beş sütunlu bir tablo dar ekranda balonu taşırırdı.
5640  return `<div class="mz-md-tablo-kap"><table class="mz-md-tablo">`
5641    + `<thead><tr>${th}</tr></thead>`
5642    + (tr ? `<tbody>${tr}</tbody>` : '')
5643    + `</table></div>`;
5644}
5645
5646/** Tablo bloklarını çıkar, yerlerine yer tutucu koy. Döner: {metin, tablolar}. */
5647function _tablolariAyikla(h) {
5648  if (h.indexOf('|') === -1) return { metin: h, tablolar: [] };   // ucuz eleme
5649  const satirlar = h.split('\n');
5650  const tablolar = [];
5651  const cikti = [];
5652  for (let i = 0; i < satirlar.length; i++) {
5653    const bas = satirlar[i];
5654    const ayrac = satirlar[i + 1];
5655    const baslikliMi = bas.trim().indexOf('|') !== -1
5656      && _tabloHucreleri(bas).length >= 2;
5657    if (!baslikliMi || !ayrac || !_TABLO_AYRAC_RE.test(ayrac.trim())
5658        || ayrac.indexOf('-') === -1) {
5659      cikti.push(bas);
5660      continue;
5661    }
5662    const blok = [bas, ayrac];
5663    let j = i + 2;
5664    while (j < satirlar.length && satirlar[j].trim().indexOf('|') !== -1
5665           && satirlar[j].trim() !== '') {
5666      blok.push(satirlar[j]);
5667      j++;
5668    }
5669    tablolar.push(_tabloBloguHtml(blok));
5670    cikti.push(`\u0000TBL${tablolar.length - 1}\u0000`);
5671    i = j - 1;
5672  }
5673  return { metin: cikti.join('\n'), tablolar };
5674}
5675
5676function _tablolariGeriKoy(h, tablolar) {
5677  if (!tablolar.length) return h;
5678  return h.replace(/\u0000TBL(\d+)\u0000/g, (tam, n) => {
5679    const t = tablolar[Number(n)];
5680    return t === undefined ? tam : t;
5681  });
5682}
5683
5684function _tabloStilleriEkle() {
5685  if (document.getElementById('chatMdTabloStyles')) return;
5686  const s = document.createElement('style');
5687  s.id = 'chatMdTabloStyles';
5688  // Renkler değişkenlerden: balon hem açık hem koyu temada çiziliyor ve
5689  // sabit renk koyu modda okunmaz hale gelirdi.
5690  s.textContent = `
5691    .mz-md-tablo-kap { overflow-x: auto; margin: 8px 0; max-width: 100%; }
5692    .mz-md-tablo { border-collapse: collapse; font-size: .94em; min-width: 100%; }
5693    .mz-md-tablo th, .mz-md-tablo td {
5694      border: 1px solid var(--border, rgba(58,28,89,.18));
5695      padding: 5px 9px; vertical-align: top; white-space: nowrap;
5696    }
5697    .mz-md-tablo th {
5698      /* --bg-subtle panelin paletinde YOK; yedege dusuyordu ve yedek tek
5699         temaya gore secilmisti. --soft-neutral-bg iki temada da tanimli ve
5700         YARI SAYDAM: tablo hangi zeminin ustunde olursa olsun dogru. */
5701      background: var(--soft-neutral-bg, rgba(120,120,135,.14));
5702      font-weight: 700;
5703    }
5704    .mz-md-tablo tbody tr:nth-child(even) td {
5705      background: color-mix(in srgb, var(--soft-neutral-bg, rgba(120,120,135,.14)) 45%, transparent);
5706    }
5707  `;
5708  document.head.appendChild(s);
5709}
5710
5711
5712// DIŞA AÇIK: bilgi tabanı soru-cevap önizlemesi de bunu kullanır (bkz.
5713// bicim-cubugu.js). Ayrı bir önizleme çizici yazmak, sahibin editörde
5714// gördüğüyle müşterinin sohbette gördüğünün zamanla ayrışması demekti.
5715export function renderClaudeText(text) {
5716  const raw = String(text == null ? '' : text);
5717  if (RICH_TAG_RE.test(raw)) {
5718    // Cevap HTML içeriyor → etiketleri metin olarak basma, temizleyip uygula.
5719    let h = _mdInsideHtml(sanitizeRichHtml(raw));
5720    // Blok etiketi yoksa satır sonları kaybolmasın.
5721    if (!RICH_BLOCK_RE.test(h)) h = h.replace(/\n\n+/g, '<br><br>').replace(/\n/g, '<br>');
5722    return _haritaEtiketle(h);
5723  }
5724  // Minimal safe markdown rendering: paragraphs, bold, italic, lists, inline code.
5725  // Escape HTML first, then apply patterns.
5726  let h = _mdInline(escapeHtml(raw));
5727  // TABLOLAR ÖNCE çıkarılır (aşağıdaki `\n` → `<br>` dönüşümü tabloyu bozardı)
5728  // ve en sonda geri konur.
5729  const _tbl = _tablolariAyikla(h);
5730  h = _tbl.metin;
5731  if (_tbl.tablolar.length) _tabloStilleriEkle();
5732  // Bullet lists (- or *)
5733  h = h.replace(/(^|\n)([\-\*] .+(?:\n[\-\*] .+)*)/g, (_, pre, block) => {
5734    const items = block.split(/\n/).map(line => '<li>' + line.replace(/^[\-\*]\s+/, '') + '</li>').join('');
5735    return pre + '<ul style="margin:6px 0 6px 18px;padding:0;">' + items + '</ul>';
5736  });
5737  // Double newline → paragraph break
5738  h = h.replace(/\n\n+/g, '<br><br>');
5739  // Single newline → soft break
5740  h = h.replace(/\n/g, '<br>');
5741  h = _tablolariGeriKoy(h, _tbl.tablolar);
5742  return _haritaEtiketle(h);
5743}
5744
5745// Claude-style whimsical "thinking" words, but on-brand for Morfoz (kahve +
5746// kelebek + örgü temaları). Rotates while the assistant composes its reply.
5747// Anahtarlar ÇEVİRİ ZAMANI çözülür (dizi sabit değil, fonksiyon): kullanıcı dil
5748// değiştirdiğinde bir sonraki bekleme etiketi yeni dilde gelsin.
5749const MORFOZ_THINKING_KEYS = [
5750  'chat.think.brewing', 'chat.think.blending', 'chat.think.thinking',
5751  'chat.think.filtering', 'chat.think.simmering', 'chat.think.gathering',
5752  'chat.think.fermenting', 'chat.think.fluttering', 'chat.think.weaving',
5753  'chat.think.focusing',
5754];
5755
5756function makeTypingIndicator() {
5757  const row = document.createElement('div');
5758  row.className = 'msg-row typing-row';
5759  // CRITICAL: mark decorated so the prototype's inline-training MutationObserver
5760  // does NOT bolt a conformity ("uyum") pill onto the typing bubble.
5761  row.dataset.trainDecorated = '1';
5762  let i = Math.floor(Math.random() * MORFOZ_THINKING_KEYS.length);
5763  const word = () => t(MORFOZ_THINKING_KEYS[i % MORFOZ_THINKING_KEYS.length]) + '…';
5764  row.innerHTML = `
5765    <div class="avatar tone-2">M</div>
5766    <div class="msg-stack">
5767      <div class="bubble" style="padding:8px 12px;display:flex;align-items:center;gap:10px;">
5768        <iframe src="/animation/typing-butterfly.html"
5769                style="width:56px;height:56px;border:0;background:transparent;flex-shrink:0;"
5770                title="Morfoz"></iframe>
5771        <span class="mz-think-label" style="font-size:12px;color:var(--text-tertiary);font-style:italic;font-family:var(--serif);">${escapeHtml(word())}</span>
5772      </div>
5773    </div>
5774  `;
5775  const span = row.querySelector('.mz-think-label');
5776  const timer = setInterval(() => {
5777    if (!row.isConnected) { clearInterval(timer); return; }
5778    i++; span.textContent = word();
5779  }, 2600);
5780  return row;
5781}
5782
5783// Walk up DOM to find the actual scrollable container of chat content.
5784// The chat-inner element itself may not be the scroller — it c
5784ould be a parent
5785// (`.chat`, `.chat-view`, etc.) depending on which view/layout is active.
5786function findScrollableAncestor(el) {
5787  let node = el;
5788  while (node && node !== document.body) {
5789    const s = getComputedStyle(node);
5790    if (/(auto|scroll)/.test(s.overflowY) && node.scrollHeight > node.clientHeight + 2) {
5791      return node;
5792    }
5793    node = node.parentElement;
5794  }
5795  return null;
5796}
5797
5798// =============== Attachment + emoji styles ===============
5799// =============== Avatar show/hide toggle ===============
5800const LS_HIDE_AVATARS = 'morfoz_hide_avatars';
5801
5802function wireAvatarToggle() {
5803  injectAvatarToggleStyles();
5804  const btn = document.getElementById('avatarToggleBtn');
5805  if (!btn) return;
5806  const apply = () => {
5807    let hidden = false;
5808    try { hidden = localStorage.getItem(LS_HIDE_AVATARS) === '1'; } catch {}
5809    document.documentElement.classList.toggle('hide-chat-avatars', hidden);
5810    const on  = btn.querySelector('.ico-on');
5811    const off = btn.querySelector('.ico-off');
5812    if (on)  on.style.display  = hidden ? 'none' : '';
5813    if (off) off.style.display = hidden ? '' : 'none';
5814    btn.title = hidden ? t('chat.avatar_show') : t('chat.avatar_hide');
5815  };
5816  apply();
5817  // Dil değişince başlıktaki ipucu da tazelensin (JS ile yazıldığı için
5818  // applyTranslations ona ulaşmaz).
5819  onLanguageChange(() => { try { apply(); } catch {} });
5820  btn.addEventListener('click', () => {
5821    let cur = false;
5822    try { cur = localStorage.getItem(LS_HIDE_AVATARS) === '1'; } catch {}
5823    try { localStorage.setItem(LS_HIDE_AVATARS, cur ? '0' : '1'); } catch {}
5824    apply();
5825  });
5826}
5827
5828function injectAvatarToggleStyles() {
5829  if (document.getElementById('chatAvatarToggleStyles')) return;
5830  const s = document.createElement('style');
5831  s.id = 'chatAvatarToggleStyles';
5832  // When the html root carries .hide-chat-avatars, hide both the user's own
5833  // avatar and the assistant avatar in every message row, and shift the bubble
5834  // back to where the avatar used to be so the layout doesn't have an empty hole.
5835  s.textContent = `
5836    html.hide-chat-avatars #chatView .msg-row .avatar { display: none !important; }
5837    html.hide-chat-avatars #chatView .msg-row { grid-template-columns: 0 1fr !important; }
5838    html.hide-chat-avatars #chatView .msg-row.self { grid-template-columns: 1fr 0 !important; }
5839  `;
5840  document.head.appendChild(s);
5841}
5842
5843/// "En alta in" düğmesi — kaydırma alanının sağ altına yapışır.
5844///
5845/// `position: sticky` + `height: 0`: akışta yer kaplamadan kaydırma alanının
5846/// dibinde durur. `fixed` olsaydı sol raya/açılır panellere binerdi, `absolute`
5847/// olsaydı kaydırma alanıyla birlikte yukarı kayardı.
5848function enAltaInDugmesiKur(scrollEl, onTikla) {
5849  const varOlan = scrollEl.querySelector(':scope > .chat-jump');
5850  if (varOlan) return varOlan;
5851  if (!document.getElementById('chatJumpStyles')) {
5852    const s = document.createElement('style');
5853    s.id = 'chatJumpStyles';
5854    s.textContent = `
5855      .chat-jump {
5856        position: sticky; bottom: 10px; z-index: 6;
5857        height: 0; display: flex; justify-content: flex-end;
5858        pointer-events: none;
5859      }
5860      .chat-jump button {
5861        pointer-events: auto;
5862        transform: translateY(-46px);
5863        width: 40px; height: 40px; border-radius: 50%;
5864        display: flex; align-items: center; justify-content: center;
5865        background: var(--surface, #fff);
5866        color: var(--text-secondary, #6B5F7E);
5867        border: 1px solid var(--border, rgba(20,10,40,0.12));
5868        box-shadow: 0 4px 14px rgba(20,10,40,0.18);
5869        cursor: pointer;
5870        opacity: 0; visibility: hidden;
5871        transition: opacity 160ms ease, color 160ms ease;
5872      }
5873      .chat-jump.visible button { opacity: 1; visibility: visible; }
5874      .chat-jump button:hover { color: var(--brand, #6B2387); }
5875      /* Konuşan-avatar (PiP) sağ altta durur ve z-index'i 30 — düğmemiz .chat'in
5876         (z-index:2) yığın bağlamı içinde olduğu için ONUN ALTINDA kalıyor ve
5877         avatarı bir kez açan kullanıcı düğmeyi hiç göremiyordu. Yığın sırasını
5878         zorlamak yerine düğmeyi avatarın soluna alıyoruz: yapısal değişiklik
5879         yok, avatar kapalıyken konum aynen korunuyor. */
5880      #chatView:has(.talking-avatar.open) .chat-jump button {
5881        transform: translateY(-46px) translateX(-248px);
5882      }
5883      .chat-jump button { position: relative; }
5884      .chat-jump-badge {
5885        position: absolute; top: -3px; right: -3px;
5886        min-width: 17px; height: 17px; padding: 0 4px;
5887        border-radius: 9px; background: var(--brand, #6B2387); color: #fff;
5888        font-size: 10px; font-weight: 700; line-height: 17px; text-align: center;
5889      }
5890    `;
5891    document.head.appendChild(s);
5892  }
5893  const kap = document.createElement('div');
5894  kap.className = 'chat-jump';
5895  const _enAlta = escapeHtml(t('chat.jump_bottom'));
5896  kap.innerHTML = `<button type="button" title="${_enAlta}" aria-label="${_enAlta}">`
5897    + '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
5898    + 'stroke-width="2.2"><path d="M12 5v14M19 12l-7 7-7-7"/></svg>'
5899    + '<span class="chat-jump-badge" style="display:none;"></span></button>';
5900  kap.querySelector('button').addEventListener('click', onTikla);
5901  scrollEl.appendChild(kap);
5902  return kap;
5903}
5904
5905// =============== Dil değişimi ===============
5906//
5907// Sohbet ekranı panel-app'in "aktif görünümü yeniden çiz" kancasının KAPSAMI
5908// DIŞINDA: o kanca yalnız `views[...]` haritasındaki JS-çizimli ekranları
5909// tazeliyor, sohbet ise statik HTML + bu modülün ürettiği balonlar. Kanca
5910// olmasaydı dil değiştiren kullanıcı sohbette Türkçe metinlerle kalırdı.
5911//
5912// İki adım var çünkü ekranın iki farklı ömürlü parçası var:
5913//   1. BİR KEZ kurulan ve varlık kontrolüyle korunan yüzeyler (üst-sağ düğmeler,
5914//      "en alta in", sürükle-bırak katmanı) — bunlar yeniden yaratılmaz,
5915//      etiketleri elle yazılmalı.
5916//   2. Konuşmanın kendisi (başlık rozetleri, balonlar, saatler, eğitim/geri
5917//      bildirim çubukları) — hepsi mesaj verisinden üretiliyor, o yüzden açık
5918//      konuşmayı yeniden çizmek TEK doğru yol; parça parça yamamak metin
5919//      üretiminin iki kopyasını yaşatmak olurdu.
5920function _dilEtiketleriniTazele() {
5921  const yaz = (id, metin) => {
5922    const el = document.getElementById(id);
5923    if (!el) return;
5924    el.title = metin;
5925    el.setAttribute('aria-label', metin);
5926  };
5927  yaz('chatAvatarBtn', t('chat.avatar_pick'));
5928  yaz('chatVoiceCallBtn', t('chat.voice_call'));
5929  yaz('chatVideoCallBtn', t('chat.video_call'));
5930  yaz('chatGroupInfoBtn', t('chat.conv_info'));
5931  const avatarBtn = document.getElementById('chatAvatarBtn');
5932  if (avatarBtn) avatarBtn.setAttribute('aria-label', t('chat.avatar'));
5933  const jump = document.querySelector('#chatView .chat-jump button');
5934  if (jump) {
5935    jump.title = t('chat.jump_bottom');
5936    jump.setAttribute('aria-label', t('chat.jump_bottom'));
5937  }
5938  const birak = document.querySelector('#chatView .birak-katmani .bk-metin');
5939  if (birak) birak.textContent = t('chat.file.drop_here');
5940  const yildiz = document.querySelector('#chatView .header-actions [data-wired="1"]');
5941  if (yildiz) yildiz.title = t('chat.act.starred_title');
5942}
5943
5944onLanguageChange(() => {
5945  try { _dilEtiketleriniTazele(); } catch (e) { console.warn('[chat] i18n labels', e); }
5946  const conv = state.activeConvInfo;
5947  if (!conv) return;
5948  // Yeniden çizim yalnız sohbet EKRANDAYKEN anlamlı; gizliyken beklemek hem
5949  // gereksiz istek hem de kullanıcının göremeyeceği bir iş. Ekran açıldığında
5950  // zaten setActiveConversation'dan geçiyor.
5951  const view = document.getElementById('chatView');
5952  if (view && view.offsetParent === null) return;
5953  Promise.resolve(setActiveConversation(conv)).catch(() => {});
5954});
5955
5956function injectAttachmentStyles() {
5957  if (document.getElementById('chatAttachmentStyles')) return;
5958  const s = document.createElement('style');
5959  s.id = 'chatAttachmentStyles';
5960  s.textContent = `
5961    .bubble-attachment { padding: 6px !important; max-width: 320px; }
5962    .attachment-card { display: flex; flex-direction: column; gap: 6px; }
5963    .attachment-card.image .att-link { display: block; line-height: 0; border-radius: 8px; overflow: hidden; }
5964    .attachment-card .att-image { width: 100%; max-width: 300px; max-height: 320px; object-fit: cover; display: block; }
5965    .attachment-card.file .att-link {
5966      display: flex; align-items: center; gap: 10px;
5967      text-decoration: none; color: inherit;
5968      padding: 8px 10px; border-radius: 8px;
5969      background: rgba(58,28,89,0.04);
5970      transition: background 140ms;
5971    }
5972    .attachment-card.file .att-link:hover { background: rgba(58,28,89,0.08); }
5973    .attachment-card .att-icon { font-size: 24px; line-height: 1; }
5974    .attachment-card .att-meta { display: flex; flex-direction: column; min-width: 0; }
5975    .attachment-card .att-name {
5976      font-weight: 600; font-size: 12.5px;
5977      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
5978      max-width: 220px;
5979    }
5980    .attachment-card .att-size {
5981      font-size: 11px; opacity: 0.7;
5982    }
5983    /* ---- Anket ---- */
5984    .poll-card { min-width: 240px; }
5985    .poll-card .pl-q { font-weight: 700; font-size: 13.5px; margin-bottom: 2px; }
5986    .poll-card .pl-sub, .poll-card .pl-foot {
5987      font-size: 10.5px; opacity: 0.7; margin: 2px 0 6px;
5988    }
5989    .poll-card .pl-foot { margin: 7px 0 0; }
5990    .poll-card .pl-opts { display: flex; flex-direction: column; gap: 5px; }
5991    .poll-card .pl-opt {
5992      position: relative; display: flex; align-items: center; gap: 7px;
5993      width: 100%; text-align: left; cursor: pointer; font-family: inherit;
5994      font-size: 12.5px; color: inherit; padding: 7px 10px;
5995      border: 1px solid rgba(58,28,89,0.16); border-radius: 8px;
5996      background: transparent; overflow: hidden;
5997    }
5998    .poll-card .pl-opt:disabled { cursor: default; opacity: 0.75; }
5999    .poll-card .pl-opt.mine { border-color: #6B2387; }
6000    /* Dolgu METNİN ALTINDA: z-index olmadan çubuk yazıyı örtüyordu. */
6001    .poll-card .pl-bar {
6002      position: absolute; left: 0; top: 0; bottom: 0; z-index: 0;
6003      background: rgba(107,35,135,0.13); transition: width 220ms ease;
6004    }
6005    .poll-card .pl-tick { position: relative; z-index: 1; width: 11px; color: var(--brand, #6B2387); font-weight: 700; }
6006    .poll-card .pl-text { position: relative; z-index: 1; flex: 1 1 auto; }
6007    .poll-card .pl-count { position: relative; z-index: 1; font-variant-numeric: tabular-nums; opacity: 0.8; }
6008    .msg-row.self .poll-card .pl-opt { border-color: rgba(255,255,255,0.35); }
6009    .msg-row.self .poll-card .pl-bar { background: rgba(255,255,255,0.24); }
6010    .msg-row.self .poll-card .pl-tick { color: #fff; }
6011    @media (prefers-reduced-motion: reduce) { .poll-card .pl-bar { transition: none; } }
6012
6013    /* ---- @bahsetme ---- */
6014    .mz-bahset {
6015      font-weight: 600;
6016      color: var(--brand, #6B2387);
6017      background: rgba(107,35,135,0.10);
6018      border-radius: 4px; padding: 0 3px;
6019    }
6020    /* Giden balonun zemini marka renginde;
6020 orada mor üstüne mor okunmuyordu. */
6021    .msg-row.self .mz-bahset {
6022      color: #fff; background: rgba(255,255,255,0.22);
6023    }
6024
6025    /* ---- Sesli not ---- */
6026    .attachment-card.voice {
6027      flex-direction: row; align-items: center; gap: 9px;
6028      padding: 7px 11px 7px 8px; border-radius: 999px;
6029      background: rgba(58,28,89,0.05); cursor: pointer; min-width: 190px;
6030    }
6031    .attachment-card.voice:hover { background: rgba(58,28,89,0.09); }
6032    .attachment-card.voice .vn-play {
6033      border: none; background: #6B2387; color: #fff; cursor: pointer;
6034      width: 28px; height: 28px; border-radius: 50%; flex: 0 0 28px;
6035      font-size: 12px; line-height: 28px; padding: 0; font-family: inherit;
6036    }
6037    .attachment-card.voice .vn-track {
6038      flex: 1 1 auto; height: 4px; border-radius: 2px;
6039      background: rgba(58,28,89,0.18); overflow: hidden; min-width: 70px;
6040    }
6041    .attachment-card.voice .vn-fill {
6042      height: 100%; width: 0%; background: #6B2387; transition: width 120ms linear;
6043    }
6044    .attachment-card.voice .vn-time {
6045      font-size: 11.5px; opacity: 0.75; font-variant-numeric: tabular-nums;
6046      flex: 0 0 auto;
6047    }
6048    /* Kayıt şeridi — besteci alanının üstünde */
6049    .vn-kayit {
6050      display: flex; align-items: center; gap: 10px;
6051      padding: 7px 12px; margin-bottom: 6px; border-radius: 10px;
6052      background: rgba(220,38,38,0.08); font-size: 12.5px;
6053    }
6054    .vn-nokta {
6055      width: 9px; height: 9px; border-radius: 50%; background: #DC2626;
6056      animation: vnYanip 1s steps(1) infinite; flex: 0 0 9px;
6057    }
6058    @keyframes vnYanip { 50% { opacity: 0.25; } }
6059    .vn-sure { font-variant-numeric: tabular-nums; font-weight: 600; }
6060    .vn-iptal {
6061      margin-left: auto; border: none; background: transparent; cursor: pointer;
6062      color: var(--danger, #DC2626); font-weight: 600; font-size: 12.5px; font-family: inherit;
6063    }
6064    #voiceBtn.recording { color: var(--danger, #DC2626); }
6065    /* Hareket azaltma tercihi: yanıp sönen nokta vestibüler rahatsızlık
6066       yapabiliyor; kayıt yine görünür kalıyor, sadece sabitleniyor. */
6067    @media (prefers-reduced-motion: reduce) {
6068      .vn-nokta { animation: none; }
6069      .attachment-card.voice .vn-fill { transition: none; }
6070    }
6071
6072    .attachment-card .att-caption {
6073      padding: 2px 6px 4px;
6074      font-size: 12.5px; line-height: 1.4;
6075    }
6076
6077    /* VIDEO ONIZLEMESI.
6078       Olcu gorselinkiyle AYNI (max 300px): balonlar ayni genislikte kalsin,
6079       sohbet listesi videoyu gorunce zipmasin.
6080       Siyah zemin: ilk kare gelene kadar seffaf/beyaz bir kutu yerine
6081       oynaticiya benzeyen bir yuzey duruyor.
6082       (Bu yorumda ters tirnak YOK -- sablon dizesinin icindeyiz.) */
6083    .attachment-card.video { position: relative; }
6084    .attachment-card .att-video {
6085      width: 100%; max-width: 300px; max-height: 320px;
6086      display: block; border-radius: 8px; background: #000;
6087    }
6088    .attachment-card .att-video-meta {
6089      padding: 0 6px; font-size: 11px; color: var(--text-tertiary, #8A7CA0);
6090      word-break: break-word;
6091    }
6092    /* Tek seferlik rozeti oynaticinin kontrol seridini KAPATMASIN:
6093       sag ALT kose oynat/ses/tam ekran dugmelerinin yeri. */
6094    .attachment-card.video .ts-rozet { top: 6px; right: 6px; bottom: auto; }
6095  `;
6096  document.head.appendChild(s);
6097}
6098
6099// =============== @bahsetme — yazarken otomatik tamamlama ===============
6100//
6101// Sunucu tarafı ZATEN VARDI (services/bahsetme.py): metindeki @handle'lar
6102// çözülüyor ve bahsedilen kişiye, grubu SESSİZE ALMIŞ olsa bile bildirim
6103// gidiyor. Eksik olan tek şey handle'ı DOĞRU YAZABİLMEKTİ: kullanıcı grup
6104// üyelerinin kullanıcı adını ezbere bilmek zorundaydı ve bir harf yanlış
6105// yazıldığında özellik SESSİZCE hiç çalışmıyordu — ne uyarı ne bildirim.
6106//
6107// Liste KONUŞMANIN ÜYELERİNDEN geliyor (sunucu da yalnız üyeleri çözüyor);
6108// genel bir kullanıcı araması olsaydı, sohbette olmayan birinin varlığını
6109// sızdırırdı.
6110
6111/** Konuşma başına üye önbelleği — her tuş vuruşunda istek atmayalım. */
6112const _bahsetUyeleri = new Map();
6113
6114async function _bahsetmeUyeleriGetir(convId) {
6115  if (_bahsetUyeleri.has(convId)) return _bahsetUyeleri.get(convId);
6116  let uyeler = [];
6117  try {
6118    const d = await api.get(`/api/conversations/${convId}/participants`);
6119    uyeler = (d.users || []).filter(u => u.handle && !u.is_me);
6120    // "@herkes" YALNIZ GRUPTA: DM'de zaten iki kişi var, "herkes" demek
6121    // karşı tarafı etiketlemekle aynı şey — gereksiz bir seçenek olurdu.
6122    if (state.activeType === 'group') {
6123      uyeler.unshift({ id: 0, name: t('chat.mention.everyone'), handle: 'herkes', hepsi: true });
6124    }
6125  } catch { /* liste alınamazsa tamamlama sessizce kapalı kalır */ }
6126  _bahsetUyeleri.set(convId, uyeler);
6127  return uyeler;
6128}
6129
6130/** İmleçten geriye doğru bakıp yazılmakta olan @parçayı bulur. */
6131function _bahsetmeParcasi(alan) {
6132  const imlec = alan.selectionStart ?? alan.value.length;
6133  const once = alan.value.slice(0, imlec);
6134  // Boşluk/satır başı sonrası gelen @ + henüz boşluk yazılmamış parça.
6135  const m = once.match(/(?:^|\s)@([A-Za-z0-9_.]{0,31})$/);
6136  if (!m) return null;
6137  return { terim: m[1].toLowerCase(), bas: imlec - m[1].length - 1, son: imlec };
6138}
6139
6140function _bahsetmeKutusu() {
6141  let el = document.getElementById('bahsetmeKutusu');
6142  if (el) return el;
6143  el = document.createElement('div');
6144  el.id = 'bahsetmeKutusu';
6145  el.style.cssText = 'position:absolute;z-index:60;min-width:220px;max-width:320px;'
6146    + 'max-height:230px;overflow:auto;background:var(--surface-1,#fff);'
6147    + 'border:1px solid var(--border,#E6DEF0);border-radius:12px;'
6148    + 'box-shadow:0 12px 34px rgba(20,10,40,.18);padding:5px;display:none;';
6149  document.body.appendChild(el);
6150  return el;
6151}
6152
6153function _bahsetmeKapat() {
6154  const el = document.getElementById('bahsetmeKutusu');
6155  if (el) { el.style.display = 'none'; el.dataset.acik = ''; }
6156}
6157
6158/**
6159 * Seçilen kişiyi metne yaz.
6160 *
6161 * Sondaki BOŞLUK şart: olmadan kullanıcı yazmaya devam ettiğinde harfler
6162 * handle'a yapışıyor ve etiket bozuluyor — "@aliMerhaba" kimseyi bulmaz.
6163 */
6164function _bahsetmeSec(alan, parca, uye) {
6165  const v = alan.value;
6166  alan.value = v.slice(0, parca.bas) + '@' + uye.handle + ' ' + v.slice(parca.son);
6167  const yeniImlec = parca.bas + uye.handle.length + 2;
6168  alan.setSelectionRange(yeniImlec, yeniImlec);
6169  alan.dispatchEvent(new Event('input', { bubbles: true }));
6170  alan.focus();
6171  _bahsetmeKapat();
6172}
6173
6174async function _bahsetmeGuncelle(alan) {
6175  const sosyal = state.activeType === 'dm' || state.activeType === 'group';
6176  if (!sosyal || !state.conversationId) return _bahsetmeKapat();
6177  const parca = _bahsetmeParcasi(alan);
6178  if (!parca) return _bahsetmeKapat();
6179
6180  const uyeler = await _bahsetmeUyeleriGetir(state.conversationId);
6181  const eslesen = uyeler.filter(u =>
6182    !parca.terim
6183    || (u.handle || '').toLowerCase().startsWith(parca.terim)
6184    || (u.name || '').toLowerCase().includes(parca.terim)
6185  ).slice(0, 8);
6186  if (!eslesen.length) return _bahsetmeKapat();
6187
6188  const kutu = _bahsetmeKutusu();
6189  kutu.innerHTML = eslesen.map((u, i) => `
6190    <button type="button" class="bh-sat" data-i="${i}"
6191      style="display:flex;align-items:center;gap:9px;width:100%;border:0;background:${i === 0 ? 'var(--surface-2,#F3EEF8)' : 'transparent'};
6192             border-radius:8px;padding:7px 9px;cursor:pointer;text-align:left;font:inherit;color:inherit;">
6193      <span class="avatar tone-${u.avatar_tone || 2}" style="width:26px;height:26px;font-size:11px;flex:none;">
6194        ${u.hepsi ? '👥' : escapeHtml((u.name || '?').slice(0, 1).toUpperCase())}</span>
6195      <span style="min-width:0;">
6196        <span style="display:block;font-size:12.5px;font-weight:600;">${escapeHtml(u.name || '')}</span>
6197        <span style="display:block;font-size:11px;color:var(--text-tertiary,#A89DB5);">@${escapeHtml(u.handle)}</span>
6198      </span>
6199    </button>`).join('');
6200
6201  // Konum: yazma alanının ÜSTÜNDE. Altına koymak dar ekranda ve klavye
6202  // açıkken listeyi görünmez yapardı.
6203  const r = alan.getBoundingClientRect();
6204  kutu.style.display = 'block';
6205  kutu.dataset.acik = '1';
6206  const y = r.top + window.scrollY - Math.min(kutu.offsetHeight, 230) - 8;
6207  kutu.style.left = (r.left + window.scrollX) + 'px';
6208  kutu.style.top = Math.max(8, y) + 'px';
6209
6210  kutu.querySelectorAll('.bh-sat').forEach(b => {
6211    b.addEventListener('mousedown', (e) => {
6212      // `mousedown` + preventDefault: `click` beklerken alan ODAĞI KAYBEDİYOR
6213      // ve `selectionStart` sıfırlanıyordu — etiket metnin başına yazılırdı.
6214      e.preventDefault();
6215      _bahsetmeSec(alan, parca, eslesen[Number(b.dataset.i)]);
6216    });
6217  });
6218  kutu._sec = (i) => _bahsetmeSec(alan, parca, eslesen[i]);
6219}
6220
6221/** Klavye: yukarı/aşağı gezinme, Enter/Tab seçme, Esc kapatma. */
6222function _bahsetmeTus(e) {
6223  const kutu = document.getElementById('bahsetmeKutusu');
6224  if (!kutu || kutu.dataset.acik !== '1') return false;
6225  const satirlar = [...kutu.querySelectorAll('.bh-sat')];
6226  if (!satirlar.length) return false;
6227  let aktif = satirlar.findIndex(s => s.style.background !== 'transparent');
6228  if (aktif < 0) aktif = 0;
6229  const boya = (i) => satirlar.forEach((s, j) => {
6230    s.style.background = i === j ? 'var(--surface-2,#F3EEF8)' : 'transparent';
6231  });
6232  if (e.key === 'ArrowDown') { e.preventDefault(); boya((aktif + 1) % satirlar.length); return true; }
6233  if (e.key === 'ArrowUp') { e.preventDefault(); boya((aktif - 1 + satirlar.length) % satirlar.length); return true; }
6234  if (e.key === 'Escape') { e.preventDefault(); _bahsetmeKapat(); return true; }
6235  if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); kutu._sec(aktif); return true; }
6236  return false;
6237}

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.