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üÄ
427me. 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 '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' 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 `&` ü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.