PageSourceSearch

https://morfoz.ai/public/takip-talep-view.js?v=1790769371

js morfoz.ai collected 2026-10-05 15:08:51 UTC 19,324 bytes, 471 lines download raw bytes

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'))}">&times;</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'))}">&times;</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.