File size: 17,130 Bytes
d33cc84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37a2937
 
f8924f9
 
 
 
 
 
 
 
 
 
d33cc84
 
37a2937
d33cc84
 
 
 
 
 
 
 
 
 
37a2937
d33cc84
1907334
d33cc84
f8924f9
d33cc84
1907334
d33cc84
 
37a2937
d33cc84
 
 
37a2937
d33cc84
 
 
 
 
 
 
 
 
 
 
 
37a2937
d33cc84
 
 
 
 
 
 
37a2937
d33cc84
 
 
 
 
37a2937
d33cc84
37a2937
d33cc84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37a2937
d33cc84
37a2937
d33cc84
 
37a2937
d33cc84
 
 
 
 
 
 
 
 
37a2937
d33cc84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37a2937
 
d33cc84
 
 
 
 
 
 
 
 
 
 
 
 
37a2937
d33cc84
 
 
 
 
 
 
 
 
 
 
37a2937
d33cc84
 
 
37a2937
 
d33cc84
37a2937
d33cc84
 
 
37a2937
 
1907334
d33cc84
 
 
37a2937
 
1907334
d33cc84
 
f8924f9
d33cc84
 
 
 
 
 
 
37a2937
d33cc84
f8924f9
d33cc84
37a2937
 
1907334
 
d33cc84
 
 
 
 
f8924f9
37a2937
 
 
d33cc84
 
 
 
 
 
 
37a2937
d33cc84
37a2937
d33cc84
1907334
37a2937
d33cc84
37a2937
d33cc84
37a2937
 
 
d33cc84
 
 
 
37a2937
f8924f9
37a2937
 
 
d33cc84
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
  <title>BubbleGuard โ€“ Safe Chat</title>

  <link rel="icon" href="logo.png">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
  <link href="styles.css" rel="stylesheet">
</head>
<body>
  <div class="app">
    <header class="glass py-2 px-3">
      <div class="container-fluid d-flex align-items-center gap-3">
        <button class="btn btn-ico" type="button" aria-label="Back" title="Back">โ€น</button>
        <div class="d-flex align-items-center gap-2">
          <img src="logo.png" alt="" class="rounded-3 header-logo" onerror="this.style.display='none'">
          <div class="d-flex flex-column lh-1">
            <div class="app-title">BubbleGuard</div>
            <div id="health" class="subtle" aria-live="polite">Checkingโ€ฆ</div>
          </div>
        </div>
        <div class="ms-auto d-flex align-items-center gap-1">
          <button id="theme" class="btn btn-ico" type="button" aria-label="Toggle theme" title="Appearance">๐ŸŒ“</button>
        </div>
      </div>
    </header>

    <main id="chat" class="container chat-wrap" aria-live="polite" aria-label="Chat history">
      <div class="row-start">
        <img src="avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
        <div class="bubble-them bubble shadow-bubble">
          <div class="copy">Hey there ๐Ÿ’– Welcome to BubbleGuardโ€™s safe chat! Share pics, voice notes, or messages โ€” weโ€™ll keep it kind.</div>
          <div class="meta">now</div>
        </div>
      </div>
    </main>

    <footer class="composer-wrap">
      <div id="replyBanner" class="reply-banner d-none" role="status" aria-live="polite">
        <div class="rb-body">
          <div class="rb-line">
            <span class="rb-label">Replying to</span>
            <span id="replySnippet" class="rb-snippet"></span>
          </div>
          <button id="replyCancel" class="btn-ico rb-close" aria-label="Cancel reply">โœ•</button>
        </div>
      </div>

      <div class="container composer">
        <input id="fileImg" type="file" accept="image/*" class="d-none" aria-hidden="true">
        <button id="btnImg" class="btn btn-ico" title="Attach image" aria-label="Attach image">๏ผ‹</button>

        <div class="input-shell">
          <textarea id="input" rows="1" placeholder="Write a message hereโ€ฆ" class="form-control input-ios" aria-label="Message input"></textarea>
          <div id="typing" class="typing d-none" aria-hidden="true">
            <span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
          </div>
        </div>

        <div class="audio-controls d-flex align-items-center gap-1">
          <button id="btnStart" class="btn btn-ico" title="Record" aria-label="Start recording">๐ŸŽค</button>
          <button id="btnStop"  class="btn btn-ico" title="Stop"   aria-label="Stop recording" disabled>โน</button>
          <span id="recTimer" class="pill subtle d-none" aria-live="polite">00:00</span>
        </div>

        <button id="btnSend" class="btn send-ios" aria-label="Send">โ†‘</button>
      </div>

      <div class="toast-zone">
        <div id="toast" class="toast ios-toast" role="alert" aria-live="assertive" aria-atomic="true">
          <div class="toast-body" id="toastBody">Hello</div>
        </div>
      </div>
    </footer>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>

  <script>
    // ---- Hard-wire your Space origin (change if you rename it) ----
    const SPACE_ORIGIN = "https://metimiester-bubbleguard.hf.space";  // <<โ€” EDIT if org/space changes
    const api = (p) => `${SPACE_ORIGIN}/${String(p).replace(/^\/+/, '')}`;

    async function fetchJSON(url, opts) {
      const res = await fetch(url, Object.assign({ headers: { 'Accept': 'application/json' } }, opts || {}));
      const ct = (res.headers.get('content-type') || '').toLowerCase();
      const text = await res.text();
      if (!ct.includes('application/json')) throw new Error('Non-JSON: ' + text.slice(0,160));
      let body; try { body = JSON.parse(text); } catch { throw new Error('Invalid JSON: ' + text.slice(0,160)); }
      if (!res.ok) throw new Error(body.detail || res.status + ' ' + res.statusText);
      return body;
    }

    // ---- Theme ----
    const setTheme = (t)=> document.documentElement.setAttribute('data-bs-theme', t);
    const saved = localStorage.getItem('bg-theme');
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    setTheme(saved || (prefersDark ? 'dark' : 'light'));
    document.getElementById('theme').onclick = () => {
      const cur = document.documentElement.getAttribute('data-bs-theme');
      const next = cur === 'dark' ? 'light' : 'dark';
      setTheme(next); localStorage.setItem('bg-theme', next);
    };

    // ---- Health ----
    (async () => {
      const el = document.getElementById('health');
      try {
        const j = await fetchJSON(api('api/health'));
        const t = j.text_thresholds || {};
        el.textContent = `Online ยท ${j.device} ยท T=${t.TEXT_UNSAFE_THR ?? '-'} ยท S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
      } catch (e) {
        console.warn('health error:', e);
        el.textContent = 'Offline โ€“ ' + (e.message || e);
      }
    })();

    // ---- Helpers ----
    const $ = (id)=>document.getElementById(id);
    const chat = $('chat'), input=$('input'), typing=$('typing');
    const btnSend=$('btnSend'), btnImg=$('btnImg'), fileImg=$('fileImg');
    const btnStart=$('btnStart'), btnStop=$('btnStop'), recTimer=$('recTimer');
    const toastEl = $('toast'), toastBody = $('toastBody');
    const toast = new bootstrap.Toast(toastEl, { delay: 4200 });

    const timeNow = () => new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
    const scrollBottom = () => { chat.scrollTop = chat.scrollHeight; };
    const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
    const esc = (s)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

    // ---- Reactions/Reply (same as before) ----
    const REACTIONS = ["๐Ÿ‘","โค๏ธ","๐Ÿ˜‚","๐Ÿ˜ฎ","๐Ÿ˜ข"];
    let replyTarget = null;

    function markDelivered(bubble, double=false){
      const meta = bubble.querySelector('.meta');
      if(!meta) return;
      let ticks = meta.querySelector('.ticks');
      if(!ticks){ ticks = document.createElement('span'); ticks.className = 'ticks'; meta.appendChild(ticks); }
      ticks.innerHTML = double ? '<span class="tick-double"></span>' : '<span class="tick-solo"></span>';
    }
    function showReactionsPop(bubble){
      hideReactionsPop();
      const pop = document.createElement('div');
      pop.className = 'react-pop'; pop.setAttribute('role','menu');
      REACTIONS.forEach(e=>{
        const b=document.createElement('button'); b.type='button'; b.textContent=e; b.setAttribute('aria-label',`React ${e}`);
        b.onclick = (ev)=>{ ev.stopPropagation(); toggleReaction(bubble, e); hideReactionsPop(); };
        pop.appendChild(b);
      });
      bubble.appendChild(pop);
      setTimeout(()=>document.addEventListener('click', hideReactionsPop, { once:true }), 0);
    }
    function hideReactionsPop(){ document.querySelectorAll('.react-pop').forEach(p=>p.remove()); }
    function toggleReaction(bubble, emoji){
      bubble._reactions = bubble._reactions || new Map();
      const meKey = `me:${emoji}`;
      if(bubble._reactions.has(meKey)) bubble._reactions.delete(meKey);
      else bubble._reactions.set(meKey, 1);
      renderReactions(bubble);
    }
    function renderReactions(bubble){
      const counts = {};
      (bubble._reactions||new Map()).forEach((v,k)=>{ const em = k.split(':')[1]; counts[em] = (counts[em]||0) + 1; });
      let row = bubble.querySelector('.react-row');
      if(!row){ row = document.createElement('div'); row.className='react-row'; bubble.appendChild(row); }
      row.innerHTML = '';
      Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([em,c])=>{
        const chip = document.createElement('span'); chip.className='react-chip'; chip.innerHTML = `${em} <span class="count">${c}</span>`;
        row.appendChild(chip);
      });
      if(Object.keys(counts).length===0) row.remove();
    }
    function startReply(bubble){
      const textNode = bubble.querySelector('.copy')?.textContent ?? '';
      replyTarget = { el: bubble, text: textNode.trim().slice(0, 60) };
      $('replySnippet').textContent = replyTarget.text || '(media)';
      $('replyBanner').classList.remove('d-none');
      bubble.classList.add('swipe-hint'); setTimeout(()=>bubble.classList.remove('swipe-hint'), 900);
    }
    function cancelReply(){ replyTarget = null; $('replyBanner').classList.add('d-none'); }
    $('replyCancel').onclick = cancelReply;

    function armBubbleInteractions(bubble, isThem=false){
      let pressTimer = null;
      const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
      const endPress   = ()=>{ clearTimeout(pressTimer); };
      bubble.addEventListener('contextmenu', (e)=>{ e.preventDefault(); showReactionsPop(bubble); });
      bubble.addEventListener('pointerdown', startPress);
      bubble.addEventListener('pointerup', endPress);
      bubble.addEventListener('pointerleave', endPress);
      if(isThem){
        let sx=0, dx=0;
        bubble.addEventListener('touchstart', (e)=>{ sx = e.touches[0].clientX; dx=0; }, {passive:true});
        bubble.addEventListener('touchmove', (e)=>{ dx = e.touches[0].clientX - sx; if(dx>12) bubble.style.transform = `translateX(${Math.min(dx, 72)}px)`; }, {passive:true});
        bubble.addEventListener('touchend', ()=>{ if(dx>56) startReply(bubble); bubble.style.transform = ''; });
      }
    }

    function bubbleMe(html) {
      const row = document.createElement('div'); row.className='row-end';
      row.innerHTML = `
        <div class="bubble-you bubble shadow-bubble">
          <div class="copy">${html}</div>
          <div class="meta">${timeNow()}</div>
        </div>
        <img src="avatar_male.png" alt="" class="avatar" onerror="this.style.display='none'">
      `;
      chat.appendChild(row); scrollBottom();
      const b = row.querySelector('.bubble-you'); armBubbleInteractions(b, false); setTimeout(()=>markDelivered(b, true), 450); return b;
    }
    function bubbleThem(html) {
      const row = document.createElement('div'); row.className='row-start';
      row.innerHTML = `
        <img src="avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
        <div class="bubble-them bubble shadow-bubble">
          <div class="copy">${html}</div>
          <div class="meta">${timeNow()}</div>
        </div>
      `;
      chat.appendChild(row); scrollBottom();
      const b = row.querySelector('.bubble-them'); armBubbleInteractions(b, true); return b;
    }

    function blastBubble({ html, side='you', preview=true, icon='๐Ÿšซ', removeDelay=900 }){
      const content = preview ? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
                              : `<span class="unsafe-icon" aria-hidden="true">${icon}</span>`;
      const bubble = side === 'you' ? bubbleMe(content) : bubbleThem(content);
      bubble.classList.add('bubble-blast'); setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
    }

    function setTyping(on){ typing.classList.toggle('d-none', !on); }
    input.addEventListener('input', ()=>{ input.style.height='auto'; input.style.height=Math.min(input.scrollHeight, 140)+'px'; });
    input.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ input.value=''; input.style.height='auto'; } if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); sendText(); } });

    const normalizeInputText = (t)=> t.replace(/[โ€™โ€˜]/g,"'").replace(/[โ€œโ€]/g,'"').replace(/\s+/g,' ').trim();

    async function sendText(){
      let t = normalizeInputText(input.value); if(!t) return;
      if(replyTarget){ const quoted = replyTarget.text ? `> ${replyTarget.text}\n` : ''; t = `${quoted}${t}`; }
      setTyping(true); btnSend.disabled=true;
      try{
        const fd = new FormData(); fd.append('text', t);
        const j = await fetchJSON(api('api/check_text'), { method: 'POST', body: fd });
        if (j.safe) { bubbleMe(esc(t)); cancelReply(); }
        else {
          blastBubble({ html: esc(t), side: 'you', preview: true, icon: '๐Ÿšซ' });
          const reason = j.reason ? ` (${j.reason}${j.unsafe_prob!=null?` ยท p=${(+j.unsafe_prob).toFixed(2)}`:''})` : '';
          showToast('Message blocked as unsafe' + reason);
        }
      }catch(e){ showToast('Error: '+e.message); }
      finally{ input.value=''; input.style.height='auto'; setTyping(false); btnSend.disabled=false; }
    }
    const btnSend = document.getElementById('btnSend'); btnSend.onclick = sendText;

    // ---- Image ----
    const btnImg = document.getElementById('btnImg'); const fileImg = document.getElementById('fileImg');
    btnImg.onclick = ()=> fileImg.click();
    fileImg.onchange = async ()=>{ if(fileImg.files[0]) await handleImage(fileImg.files[0]); fileImg.value=''; };

    async function handleImage(file){
      setTyping(true);
      try{
        const fd = new FormData(); fd.append('file', file);
        const j = await fetchJSON(api('api/check_image'), { method: 'POST', body: fd });
        if(j.safe){ const url = URL.createObjectURL(file); bubbleMe(`<img src="${url}" class="chat-image" alt="Sent image">`); }
        else { blastBubble({ html: 'Image blocked', side: 'you', preview: false, icon: '๐Ÿ–ผ๏ธ' });
               const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Image blocked as unsafe' + reason); }
      }catch(e){ showToast('Error: '+e.message); }
      finally{ setTyping(false); }
    }

    // Drag & Drop (image only)
    ['dragenter','dragover'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.add('drop'); }, false));
    ;['dragleave','drop'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.remove('drop'); }, false));
    document.addEventListener('drop', async (e)=>{ const f=e.dataTransfer?.files?.[0]; if(!f) return; if(f.type.startsWith('image/')) await handleImage(f); else showToast('Only images supported via drop.'); }, false);

    // ---- Voice ----
    let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
    const fmt = (t)=>{ const m=Math.floor(t/60), s=Math.floor(t%60); return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; };
    const pickMime = () => { const prefs=['audio/webm;codecs=opus','audio/webm','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/ogg;codecs=opus','audio/ogg']; for(const m of prefs) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; return ''; };
    async function ensureMic(){ if(!mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({audio:true}); }
    function setRecUI(r){ btnStart.disabled=r; btnStop.disabled=!r; recTimer.classList.toggle('d-none',!r); }

    const btnStart=document.getElementById('btnStart'); const btnStop=document.getElementById('btnStop'); const recTimer=document.getElementById('recTimer');
    btnStart.onclick = async ()=>{ try{ await ensureMic(); chunks=[]; const mime=pickMime(); mediaRecorder=new MediaRecorder(mediaStream, mime?{mimeType:mime}:{}); mediaRecorder.ondataavailable=e=>{ if(e.data&&e.data.size) chunks.push(e.data); }; mediaRecorder.onstop=onRecordingStop; mediaRecorder.start(250); startTs=Date.now(); tick=setInterval(()=> recTimer.textContent=fmt((Date.now()-startTs)/1000),300); setRecUI(true); setTimeout(()=>{ if(mediaRecorder && mediaRecorder.state==='recording') btnStop.click(); },60000); }catch(e){ showToast('Mic error: '+e.message); } };
    btnStop.onclick = ()=>{ if(mediaRecorder && mediaRecorder.state==='recording'){ mediaRecorder.stop(); } if(tick){ clearInterval(tick); tick=null; } setRecUI(false); };

    async function onRecordingStop(){
      try{
        setTyping(true);
        const type=mediaRecorder?.mimeType||'audio/webm'; const blob=new Blob(chunks,{type}); const fd=new FormData(); fd.append('file', blob, 'voice');
        const j = await fetchJSON(api('api/check_audio'), { method:'POST', body: fd });
        if(j.safe){ const url=URL.createObjectURL(blob); bubbleMe(`<audio controls src="${url}" class="audio-ios"></audio>`); }
        else { blastBubble({ html: 'Voice note blocked', side: 'you', preview: false, icon: '๐Ÿ”Š' });
               const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Voice note blocked as unsafe' + reason); }
      }catch(e){ showToast('Error: '+e.message); }
      finally{ setTyping(false); }
    }
  </script>
</body>
</html>