Spaces:
Running
Running
| <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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| // ---- 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> | |