1/** 2 * TAKİP İZİN TALEBİ â sohbetteki kart ve toplu onay ekranı. 3 * 4 * NEDEN VAR: dökümün açılması KARÅILIKLI rızaya baÄlı (bkz. 5 * services/takip_dokum.py) ama karÅı tarafa sormanın hiçbir yolu yoktu. 6 * Herkesin kendiliÄinden Hesabım â Takip ekranını bulup aynı sohbeti açması 7 * bekleniyordu; olmuyordu. Bu dosya sorunun görünen yüzü: talebi SOHBETİN 8 * İÃİNDE gösteriyor ve tek ekranda toplu onaylatıyor. 9 * 10 * â KART SOHBETE MESAJ DEÄİLDİR. Åerit `composer-wrap`ın üstüne çiziliyor, 11 * `messages` tablosuna hiçbir satır girmiyor (sunucu tarafında da öyle). 12 * Gerçek mesaj olsaydı dökümü açılan sohbette EÄİTİM METNİNE karıÅır ve 13 * klon, kendisi için izin istendiÄini anlatan cümleyi öÄrenirdi. 14 * 15 * â TEK İSTEK. Sohbet baÅına ayrı uç YOK: bekleyen taleplerin tamamı bir 16 * kez çekilip bellekte süzülüyor. Sohbet baÅına sorsaydık 40 konuÅmalık 17 * bir listede 40 istek atardık. 18 * 19 * â confirm() KULLANILMIYOR. Tarayıcı kutuları susturulduÄunda akıŠSESSİZCE 20 * ölüyor; onay kendi ekranımızda alınıyor. 21 */ 22import { api, escapeHtml, toast } from './api-client.js'; 23import { t } from './i18n.js'; 24 25const KART = 'tkTalepSerit'; 26const KATMAN = 'tkTalepKatman'; 27 28let _bekleyen = []; 29let _yuklendi = false; 30 31/** Panel açılıÅında bir kez. */ 32export function initTakipTalep() { 33 _stilKur(); 34 // Kart açık sohbete baÄlı: rota deÄiÅince yeniden karar veriliyor. 35 window.addEventListener('hashchange', () => { _kartCiz(); }); 36 // ÃLà KABLO DÃZELTMESİ: burada `mz:takip-talep-tazele` dinleniyordu ama 37 // o olayı depoda YAYAN KİMSE YOKTU. Sonuç: izin talebi canlı geldiÄinde 38 // sohbetteki kart tazelenmiyor, kullanıcı ancak paneli yeniden yükleyince 39 // görüyordu. Artık bildirim merkezi canlı bildirimi `morfoz:bildirim` 40 // olarak yayıyor; burada yalnız kendi türümüzü süzüyoruz. 41 window.addEventListener('morfoz:bildirim', (e) => { 42 if (e?.detail?.kind === 'takip_izin') tazele(); 43 }); 44 tazele(); 45} 46 47/** Bekleyen talepleri yeniden çek ve kartı güncelle. */ 48export async function tazele() { 49 try { 50 const r = await api.get('/api/takip/talepler'); 51 _bekleyen = Array.isArray(r?.requests) ? r.requests : []; 52 _yuklendi = true; 53 } catch (e) { 54 // Sessiz: bu ekranın yokluÄu panelin geri kalanını etkilememeli. 55 _bekleyen = []; 56 } 57 _kartCiz(); 58} 59 60/** 61 * Sohbet menüsünden "EÄitim izni iste". 62 * 63 * â GRUPTA ÃNCE KİME SORULACAÄI SEÃİLİR. Eskiden bu düÄme doÄrudan uca 64 * gidiyordu, uç da sohbetteki HERKESE talep açıyordu: 40 kiÅilik bir 65 * grupta tek tıkla 39 kiÅinin telefonu ötüyordu ve geri alınamıyordu 66 * (talep bildirimi gitmiÅ oluyor). Artık grupta seçim ekranı çıkıyor; 67 * iki kiÅilik sohbette çıkmıyor, çünkü orada "diÄerleri" tek kiÅi ve 68 * ekran sormaktan baÅka bir Åey yapmazdı. 69 * 70 * chat-list.js çaÄırıyor. 71 */ 72export async function talepGonder(conversationId) { 73 let d = null; 74 try { 75 d = await api.get(`/api/takip/conversations/${conversationId}/durum`); 76 } catch (e) { 77 // Durum alınamadıysa KÃRLEMESİNE HERKESE SORMA â eski kusur tam buydu. 78 toast(e.message, 'error'); 79 return null; 80 } 81 if (!d?.ok) { 82 toast(d?.sebep || t('takip.talep.zaten_soruldu')); 83 return null; 84 } 85 86 const digerleri = (d.katilimcilar || []).filter(x => !x.ben); 87 if (!digerleri.length) { 88 toast(t('takip.talep.kimse_yok')); 89 return null; 90 } 91 // Sorulabilir = hiç sorulmamıÅ. Bekleyen/reddeden/onaylayan için bu uç 92 // zaten bir Åey yapmıyor; onların yolu bilgi ekranındaki "Tekrar iste". 93 const sorulabilir = digerleri.filter(x => x.durum === 'sorulmadi'); 94 if (!sorulabilir.length) { 95 toast(t('takip.talep.zaten_soruldu')); 96 return null; 97 } 98 // İki kiÅilik sohbet: seçilecek bir Åey yok, doÄrudan sor. 99 if (digerleri.length === 1) { 100 return _talepAt(conversationId, [sorulabilir[0].user_id]); 101 } 102 return kimeSorAc(conversationId, digerleri); 103} 104 105/** Seçilen kimliklere talebi gönder ve sonucu bildir. */ 106async function _talepAt(conversationId, hedefler) { 107 const r = await api.post( 108 `/api/takip/conversations/${conversationId}/talep`, { hedefler });
109 const n = Number(r?.gonderilen || 0); 110 toast(n > 0 111 ? t('takip.talep.gonderildi', { count: n }) 112 : (r?.sebep || t('takip.talep.zaten_soruldu'))); 113 return r; 114} 115 116const SECICI = 'tkTalepKimKatman'; 117 118/** 119 * "Kime soralım?" seçim ekranı (grup sohbetleri). 120 * 121 * Zaten onaylamıÅ/sorulmuÅ kiÅiler LİSTEDE DURUYOR ama seçilemiyor: onları 122 * gizleseydik kullanıcı "Ahmet'e neden sorulmadı" sorusunun cevabını hiçbir 123 * yerde göremezdi. 124 */ 125export function kimeSorAc(conversationId, kisiler) { 126 document.getElementById(SECICI)?.remove(); 127 const sorulabilir = kisiler.filter(x => x.durum === 'sorulmadi'); 128 129 const katman = document.createElement('div'); 130 katman.id = SECICI; 131 katman.className = 'tkt-katman'; 132 katman.innerHTML = ` 133 <div class="tkt-kutu" role="dialog" aria-modal="true"> 134 <div class="tkt-bas"> 135 <h3>${escapeHtml(t('takip.talep.kime_bas'))}</h3> 136 <button type="button" class="tkt-x" data-act="kapat" aria-label="${ 137 escapeHtml(t('common.close'))}">×</button> 138 </div> 139 <p class="tkt-alt">${escapeHtml(t('takip.talep.kime_alt'))}</p> 140 <label class="tkt-satir tkt-hepsi"> 141 <input type="checkbox" data-rol="hepsi"> 142 <span class="tkt-ad">${escapeHtml(t('takip.talep.hepsini_sec'))}</span> 143 <span class="tkt-sayi">${sorulabilir.length}</span> 144 </label> 145 <div class="tkt-liste"> 146 ${kisiler.map(x => { 147 const acik = x.durum === 'sorulmadi'; 148 return ` 149 <label class="tkt-satir${acik ? '' : ' tkt-pasif'}"> 150 <input type="checkbox" data-uid="${x.user_id}"${ 151 acik ? '' : ' disabled'}> 152 <span class="tkt-ic"> 153 <span class="tkt-ad">${escapeHtml(x.ad)}</span> 154 ${acik ? '' : `<span class="tkt-alt2">${escapeHtml( 155 t('takip.talep.zaten_' + x.durum))}</span>`} 156 </span> 157 </label>`; 158 }).join('')} 159 </div> 160 <div class="tkt-eylem"> 161 <button type="button" class="tkt-btn tkt-btn-ana" data-act="gonder" disabled>${ 162 escapeHtml(t('takip.talep.iste'))}</button> 163 <button type="button" class="tkt-btn" data-act="kapat">${ 164 escapeHtml(t('action.cancel'))}</button> 165 </div> 166 <p class="tkt-not">${escapeHtml(t('takip.talep.kime_not'))}</p> 167 </div>`; 168 document.body.appendChild(katman); 169 170 const kutular = () => [...katman.querySelectorAll('input[data-uid]:not([disabled])')]; 171 const secili = () => kutular().filter(c => c.checked).map(c => Number(c.dataset.uid)); 172 const hepsi = katman.querySelector('input[data-rol="hepsi"]'); 173 const btn = katman.querySelector('[data-act="gonder"]'); 174 175 function durumTazele() { 176 const n = secili().length; 177 const toplam = kutular().length; 178 btn.disabled = n === 0; 179 btn.textContent = n 180 ? t('takip.talep.iste_n', { count: n }) 181 : t('takip.talep.iste'); 182 hepsi.checked = n === toplam && toplam > 0; 183 hepsi.indeterminate = n > 0 && n < toplam; 184 } 185 hepsi.disabled = sorulabilir.length === 0; 186 hepsi.onchange = () => { 187 kutular().forEach(c => { c.checked = hepsi.checked; }); 188 durumTazele(); 189 }; 190 katman.addEventListener('change', (e) => { 191 if (e.target.matches('input[data-uid]')) durumTazele(); 192 }); 193 194 btn.onclick = async () => { 195 const hedefler = secili(); 196 if (!hedefler.length) return; 197 btn.disabled = true; 198 const eski = btn.textContent; 199 btn.textContent = t('takip.talep.gonderiliyor'); 200 try { 201 await _talepAt(conversationId, hedefler); 202 katman.remove(); 203 } catch (e) { 204 toast(e.message, 'error'); 205 btn.textContent = eski; 206 durumTazele(); 207 } 208 }; 209 katman.querySelectorAll('[data-act="kapat"]').forEach(b => { 210 b.onclick = () => katman.remove(); 211 }); 212 katman.addEventListener('click', (e) => { 213 if (e.target === katman) katman.remove(); 214 }); 215 durumTazele(); 216 return null; 217} 218 219/** Açık sohbetin kimliÄi (#messages/12 ya da #chats/12). */ 220function _acikKonusma() { 221 const h = String(location.hash || ''); 222 const m = h.match(/#(?:messages|chats)\/(\d+)/); 223 return m ? Number(m[1]) : null; 224} 225 226function _kartiKaldir() { 227 document.getElementById(KART)?.remove(); 228} 229 230function _kartCiz() { 231 if (!_yuklendi) return; 232 _kartiKaldir(); 233 if (!_bekleyen.length) return; 234 const kid = _acikKonusma(); 235 // Açık sohbete ait talep varsa ONU, yoksa hiçbir Åey göstermiyoruz: 236 // sohbetin içinde baÅka bir sohbetin talebini göstermek kafa karıÅtırır, 237 // toplu ekran zaten Hesabım â Takip'ten ve bildirimden eriÅilebilir. 238 if (!kid) return; 239 const talep = _bekleyen.find(x => Number(x.conversation_id) === kid); 240 if (!talep) return; 241 242 const composerWrap = document.querySelector('#chatView .composer-wrap') 243 || document.querySelector('.composer-wrap'); 244 if (!composerWrap || !composerWrap.parentNode) return; 245 246 const el = document.createElement('div');
247 el.id = KART; 248 el.className = 'tkt-serit'; 249 el.innerHTML = ` 250 <span class="tkt-serit-ikon" aria-hidden="true"> 251 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" 252 stroke="currentColor" stroke-width="2"> 253 <path d="M22 10L12 5 2 10l10 5 10-5z"/><path d="M6 12v5l6 3 6-3v-5"/> 254 </svg></span> 255 <span class="tkt-serit-yazi">${escapeHtml( 256 t('takip.talep.kart', { name: talep.isteyen }))}</span> 257 <button type="button" class="tkt-btn tkt-btn-ana" data-act="incele">${ 258 escapeHtml(t('takip.talep.incele'))}</button> 259 <button type="button" class="tkt-btn" data-act="kapat">${ 260 escapeHtml(t('takip.talep.simdi_degil'))}</button>`; 261 composerWrap.parentNode.insertBefore(el, composerWrap); 262 263 el.querySelector('[data-act="incele"]').onclick = () => ekranAc(); 264 // "Åimdi deÄil" REDDETMEK DEÄİL: yalnız kartı gizler. Reddetmek kalıcı bir 265 // karar (bir daha sorulamıyor); onu tek tıkla, yanlıÅlıkla verdirmeyiz. 266 el.querySelector('[data-act="kapat"]').onclick = () => _kartiKaldir(); 267} 268 269/** 270 * Toplu onay ekranı. 271 * 272 * @param {number|null} odakKonusma Bildirimden gelindiyse o konuÅmanın 273 * talepleri baÅtan iÅaretli açılır: kullanıcı bildirime bastıÄı için 274 * ORADA, listede onu tekrar aramak zorunda kalmamalı. DiÄerleri iÅaretsiz 275 * duruyor â bildirim bir talebi iÅaret ediyor, hepsini deÄil. 276 */ 277export function ekranAc(odakKonusma = null) { 278 document.getElementById(KATMAN)?.remove(); 279 if (!_bekleyen.length) { 280 toast(t('takip.talep.yok')); 281 return; 282 } 283 const katman = document.createElement('div'); 284 katman.id = KATMAN; 285 katman.className = 'tkt-katman'; 286 katman.innerHTML = ` 287 <div class="tkt-kutu" role="dialog" aria-modal="true"> 288 <div class="tkt-bas"> 289 <h3>${escapeHtml(t('takip.talep.ekran_bas'))}</h3> 290 <button type="button" class="tkt-x" data-act="kapat" aria-label="${ 291 escapeHtml(t('common.close'))}">×</button> 292 </div> 293 <p class="tkt-alt">${escapeHtml(t('takip.talep.ekran_alt'))}</p> 294 <label class="tkt-satir tkt-hepsi"> 295 <input type="checkbox" data-rol="hepsi"> 296 <span class="tkt-ad">${escapeHtml(t('takip.talep.hepsini_sec'))}</span> 297 <span class="tkt-sayi">${_bekleyen.length}</span> 298 </label> 299 <div class="tkt-liste"> 300 ${_bekleyen.map(x => ` 301 <label class="tkt-satir"> 302 <input type="checkbox" data-id="${x.id}"> 303 <span class="tkt-ic"> 304 <span class="tkt-ad">${escapeHtml(x.isteyen)}</span> 305 <span class="tkt-alt2">${escapeHtml( 306 t(x.grup ? 'takip.talep.grup_satir' : 'takip.talep.dm_satir', 307 { chat: x.sohbet, count: x.mesaj }))}</span> 308 </span> 309 </label>`).join('')} 310 </div> 311 <div class="tkt-eylem"> 312 <button type="button" class="tkt-btn tkt-btn-ana" data-act="onayla" disabled>${ 313 escapeHtml(t('takip.talep.onayla'))}</button> 314 <button type="button" class="tkt-btn tkt-btn-ret" data-act="reddet" disabled>${ 315 escapeHtml(t('takip.talep.reddet'))}</button> 316 <button type="button" class="tkt-btn" data-act="kapat">${ 317 escapeHtml(t('action.cancel'))}</button> 318 </div> 319 <p class="tkt-not">${escapeHtml(t('takip.talep.not'))}</p> 320 </div>`; 321 document.body.appendChild(katman); 322 323 const kutular = () => [...katman.querySelectorAll('input[data-id]')]; 324 const secili = () => kutular().filter(c => c.checked).map(c => Number(c.dataset.id)); 325 const hepsi = katman.querySelector('input[data-rol="hepsi"]'); 326 const btnOnay = katman.querySelector('[data-act="onayla"]'); 327 const btnRet = katman.querySelector('[data-act="reddet"]'); 328 329 function durumTazele() { 330 const n = secili().length; 331 btnOnay.disabled = n === 0; 332 btnRet.disabled = n === 0; 333 btnOnay.textContent = n 334 ? t('takip.talep.onayla_n', { count: n }) 335 : t('takip.talep.onayla'); 336 // Ana kutu üç durumlu: hiçbiri / bir kısmı / hepsi. 337 const toplam = kutular().length; 338 hepsi.checked = n === toplam && toplam > 0; 339 hepsi.indeterminate = n > 0 && n < toplam; 340 } 341 342 hepsi.onchange = () => {
343 kutular().forEach(c => { c.checked = hepsi.checked; }); 344 durumTazele(); 345 }; 346 katman.addEventListener('change', (e) => { 347 if (e.target.matches('input[data-id]')) durumTazele(); 348 }); 349 350 async function cevapla(onay) { 351 const ids = secili(); 352 if (!ids.length) return; 353 btnOnay.disabled = btnRet.disabled = true; 354 // İLERLEME GÃSTERGESİ: 20 talebin onayı 20 ayrı izin yazımı demek, 355 // anında bitmiyor. DüÄme sessiz kalırsa kullanıcı tekrar basar. 356 const eski = btnOnay.textContent; 357 btnOnay.textContent = t('takip.talep.gonderiliyor'); 358 try { 359 const r = await api.post('/api/takip/talepler/cevap', { ids, onay }); 360 const atlanan = Array.isArray(r?.atlanan) ? r.atlanan : []; 361 if (onay) { 362 toast(t('takip.talep.onaylandi', { count: r?.onaylanan || 0 })); 363 } else { 364 toast(t('takip.talep.reddedildi', { count: r?.reddedilen || 0 })); 365 } 366 // SESSİZ DÃÅÃRME YOK: geçmeyen talep varsa sebebiyle söyleniyor. 367 if (atlanan.length) { 368 toast(t('takip.talep.atlanan', { 369 count: atlanan.length, reason: atlanan[0]?.sebep || '', 370 }), 'error'); 371 } 372 katman.remove(); 373 await tazele(); 374 } catch (e) { 375 toast(e.message, 'error'); 376 btnOnay.textContent = eski; 377 durumTazele(); 378 } 379 } 380 381 // Bildirimden gelindiyse o konuÅmanın satır(lar)ını iÅaretle ve görünür 382 // kıl. Sadece iÅaretlemek yetmez: 20 talepli bir listede iÅaretli satır 383 // kaydırma alanının dıÅında kalabilir ve düÄme "Onayla (1)" derken hangi 384 // satır olduÄu görünmezdi. 385 if (odakKonusma) { 386 let ilk = null; 387 _bekleyen.forEach(x => { 388 if (Number(x.conversation_id) !== Number(odakKonusma)) return; 389 const c = katman.querySelector(`input[data-id="${x.id}"]`); 390 if (!c) return; 391 c.checked = true; 392 if (!ilk) ilk = c; 393 }); 394 if (ilk) { 395 ilk.closest('.tkt-satir')?.classList.add('tkt-odak');
396 try { ilk.closest('.tkt-satir')?.scrollIntoView({ block: 'nearest' }); } catch {} 397 } 398 } 399 400 btnOnay.onclick = () => cevapla(true); 401 btnRet.onclick = () => cevapla(false); 402 katman.querySelectorAll('[data-act="kapat"]').forEach(b => { 403 b.onclick = () => katman.remove(); 404 }); 405 // DıÅarı tıklama kapatır; içeri tıklama kapatmaz. 406 katman.addEventListener('click', (e) => { 407 if (e.target === katman) katman.remove(); 408 }); 409 durumTazele(); 410} 411 412function _stilKur() { 413 if (document.getElementById('tkt-stil')) return; 414 const s = document.createElement('style'); 415 s.id = 'tkt-stil'; 416 // PALET: yalnız panelin İKİ TEMADA DA tanımlı jetonları kullanılıyor 417 // (--surface, --bg-soft, --border-strong, --text-*, --brand). Bu dosyanın 418 // kardeÅi takip-view.js bir dönem --zemin/--kabartma kullanmıÅtı; o ikisi 419 // yalnız cagri.html'in kendi :root'unda tanımlı olduÄu için panelde 420 // çözülemiyor ve gömülü KOYU yedeÄe düÅüyordu: açık temada kutu siyah 421 // çiziliyordu. Yedekler de burada açık temaya göre. 422 s.textContent = ` 423 .tkt-serit{display:flex;align-items:center;gap:10px;margin:0 22px 8px; 424 padding:9px 12px;border-radius:10px;font-size:12.5px; 425 background:color-mix(in srgb,var(--brand,#6B2387) 10%,transparent); 426 border:1px solid color-mix(in srgb,var(--brand,#6B2387) 28%,transparent); 427 color:var(--text-primary,#1F1430)} 428 .tkt-serit-ikon{display:flex;color:var(--brand,#6B2387);flex:none} 429 .tkt-serit-yazi{flex:1;min-width:0;line-height:1.4} 430 .tkt-btn{padding:7px 13px;border-radius:8px;font-size:12.5px;cursor:pointer; 431 border:1px solid var(--border-strong,rgba(58,28,89,.14)); 432 background:transparent;color:inherit;font-family:inherit} 433 .tkt-btn[disabled]{opacity:.5;cursor:default} 434 .tkt-btn-ana{background:var(--brand,#6B2387);border-color:transparent;color:#fff} 435 .tkt-btn-ret{border-color:var(--danger,#B83838);color:var(--danger,#B83838)} 436 .tkt-katman{position:fixed;inset:0;z-index:9000;display:flex; 437 align-items:center;justify-content:center;padding:20px; 438 background:rgba(0,0,0,.45)} 439 .tkt-kutu{width:100%;max-width:460px;max-height:86vh;overflow:auto; 440 border-radius:14px;padding:18px 20px; 441 background:var(--surface,#FFFFFF);color:var(--text-primary,#1F1430); 442 border:1px solid var(--border-strong,rgba(58,28,89,.14))} 443 .tkt-bas{display:flex;align-items:center;gap:10px;margin-bottom:4px} 444 .tkt-bas h3{margin:0;font-size:16px;flex:1} 445 .tkt-x{background:transparent;border:0;font-size:24px;line-height:1; 446 cursor:pointer;color:var(--text-tertiary,#A89DB5);padding:0 4px} 447 .tkt-alt{color:var(--text-tertiary,#A89DB5);font-size:12.5px; 448 margin:0 0 12px;line-height:1.5} 449 .tkt-liste{max-height:46vh;overflow:auto;margin-bottom:14px} 450 .tkt-satir{display:flex;gap:10px;align-items:center;cursor:pointer; 451 padding:8px 10px;border-radius:8px} 452 .tkt-satir:hover{background:var(--bg-soft,#E9E2F0)} 453 /* Seçilemeyen satır: sorulmuÅ/onaylamıŠkiÅiler listede DURUYOR 454 (gizlenseydi 'ona neden sorulmadı' cevapsız kalırdı) ama 455 tıklanmıyor. */ 456 .tkt-pasif{cursor:default;opacity:.55} 457 /* Bildirimden gelinen satır: hangisi olduÄu görünsün. */ 458 .tkt-odak{background:var(--bg-soft,#E9E2F0); 459 box-shadow:inset 0 0 0 1px var(--brand,#6C3FC5)} 460 .tkt-pasif:hover{background:transparent} 461 .tkt-hepsi{border-bottom:1px solid var(--border,rgba(58,28,89,.08)); 462 border-radius:8px 8px 0 0;margin-bottom:4px;font-weight:600} 463 .tkt-ic{display:flex;flex-direction:column;min-width:0} 464 .tkt-ad{font-size:13.5px} 465 .tkt-alt2{color:var(--text-tertiary,#A89DB5);font-size:11.5px} 466 .tkt-sayi{margin-left:auto;color:var(--text-tertiary,#A89DB5);font-size:12px} 467 .tkt-eylem{display:flex;gap:8px;flex-wrap:wrap} 468 .tkt-not{color:var(--text-tertiary,#A89DB5);font-size:11.5px; 469 margin:12px 0 0;line-height:1.5}`; 470 document.head.appendChild(s); 471}
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.