MetiMiester commited on
Commit
37a2937
·
verified ·
1 Parent(s): d4336c3

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +42 -149
index.html CHANGED
@@ -5,9 +5,6 @@
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
  <title>BubbleGuard – Safe Chat</title>
7
 
8
- <!-- Force your Space (owner/space). Change if you rename it. -->
9
- <meta name="hf-space" content="MetiMiester/BubbleGuard">
10
-
11
  <link rel="icon" href="logo.png">
12
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
13
  <link href="styles.css" rel="stylesheet">
@@ -82,23 +79,8 @@
82
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
83
 
84
  <script>
85
- // ----- API origin (prefer your Space container) -----
86
- function computeSpaceOrigin() {
87
- const meta = document.querySelector('meta[name="hf-space"]')?.content;
88
- if (meta && meta.includes('/')) {
89
- const [org, space] = meta.split('/');
90
- return `https://${org}-${space}.hf.space`.toLowerCase();
91
- }
92
- if (location.hostname.endsWith('.hf.space')) return location.origin;
93
- if (location.hostname.endsWith('huggingface.co')) {
94
- const m = location.pathname.match(/^\/spaces\/([^/]+)\/([^/?#]+)/);
95
- if (m) return `https://${m[1]}-${m[2]}.hf.space`.toLowerCase();
96
- }
97
- const rm = (document.referrer||'').match(/huggingface\.co\/spaces\/([^/]+)\/([^/?#]+)/);
98
- if (rm) return `https://${rm[1]}-${rm[2]}.hf.space`.toLowerCase();
99
- return location.origin;
100
- }
101
- const SPACE_ORIGIN = computeSpaceOrigin();
102
  const api = (p) => `${SPACE_ORIGIN}/${String(p).replace(/^\/+/, '')}`;
103
 
104
  async function fetchJSON(url, opts) {
@@ -111,7 +93,7 @@
111
  return body;
112
  }
113
 
114
- // ----- Theme -----
115
  const setTheme = (t)=> document.documentElement.setAttribute('data-bs-theme', t);
116
  const saved = localStorage.getItem('bg-theme');
117
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -122,7 +104,7 @@
122
  setTheme(next); localStorage.setItem('bg-theme', next);
123
  };
124
 
125
- // ----- Health -----
126
  (async () => {
127
  const el = document.getElementById('health');
128
  try {
@@ -131,11 +113,11 @@
131
  el.textContent = `Online · ${j.device} · T=${t.TEXT_UNSAFE_THR ?? '-'} · S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
132
  } catch (e) {
133
  console.warn('health error:', e);
134
- el.textContent = 'Checking…';
135
  }
136
  })();
137
 
138
- // ----- DOM helpers -----
139
  const $ = (id)=>document.getElementById(id);
140
  const chat = $('chat'), input=$('input'), typing=$('typing');
141
  const btnSend=$('btnSend'), btnImg=$('btnImg'), fileImg=$('fileImg');
@@ -148,7 +130,7 @@
148
  const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
149
  const esc = (s)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
150
 
151
- // ----- Reactions & Reply -----
152
  const REACTIONS = ["👍","❤️","😂","😮","😢"];
153
  let replyTarget = null;
154
 
@@ -156,22 +138,15 @@
156
  const meta = bubble.querySelector('.meta');
157
  if(!meta) return;
158
  let ticks = meta.querySelector('.ticks');
159
- if(!ticks){
160
- ticks = document.createElement('span');
161
- ticks.className = 'ticks';
162
- meta.appendChild(ticks);
163
- }
164
  ticks.innerHTML = double ? '<span class="tick-double"></span>' : '<span class="tick-solo"></span>';
165
  }
166
-
167
  function showReactionsPop(bubble){
168
  hideReactionsPop();
169
  const pop = document.createElement('div');
170
- pop.className = 'react-pop';
171
- pop.setAttribute('role','menu');
172
  REACTIONS.forEach(e=>{
173
- const b=document.createElement('button');
174
- b.type='button'; b.textContent=e; b.setAttribute('aria-label',`React ${e}`);
175
  b.onclick = (ev)=>{ ev.stopPropagation(); toggleReaction(bubble, e); hideReactionsPop(); };
176
  pop.appendChild(b);
177
  });
@@ -179,7 +154,6 @@
179
  setTimeout(()=>document.addEventListener('click', hideReactionsPop, { once:true }), 0);
180
  }
181
  function hideReactionsPop(){ document.querySelectorAll('.react-pop').forEach(p=>p.remove()); }
182
-
183
  function toggleReaction(bubble, emoji){
184
  bubble._reactions = bubble._reactions || new Map();
185
  const meKey = `me:${emoji}`;
@@ -189,32 +163,22 @@
189
  }
190
  function renderReactions(bubble){
191
  const counts = {};
192
- (bubble._reactions||new Map()).forEach((v,k)=>{
193
- const em = k.split(':')[1];
194
- counts[em] = (counts[em]||0) + 1;
195
- });
196
  let row = bubble.querySelector('.react-row');
197
- if(!row){
198
- row = document.createElement('div'); row.className='react-row';
199
- bubble.appendChild(row);
200
- }
201
  row.innerHTML = '';
202
  Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([em,c])=>{
203
- const chip = document.createElement('span');
204
- chip.className='react-chip';
205
- chip.innerHTML = `${em} <span class="count">${c}</span>`;
206
  row.appendChild(chip);
207
  });
208
  if(Object.keys(counts).length===0) row.remove();
209
  }
210
-
211
  function startReply(bubble){
212
  const textNode = bubble.querySelector('.copy')?.textContent ?? '';
213
  replyTarget = { el: bubble, text: textNode.trim().slice(0, 60) };
214
  $('replySnippet').textContent = replyTarget.text || '(media)';
215
  $('replyBanner').classList.remove('d-none');
216
- bubble.classList.add('swipe-hint');
217
- setTimeout(()=>bubble.classList.remove('swipe-hint'), 900);
218
  }
219
  function cancelReply(){ replyTarget = null; $('replyBanner').classList.add('d-none'); }
220
  $('replyCancel').onclick = cancelReply;
@@ -223,23 +187,15 @@
223
  let pressTimer = null;
224
  const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
225
  const endPress = ()=>{ clearTimeout(pressTimer); };
226
-
227
  bubble.addEventListener('contextmenu', (e)=>{ e.preventDefault(); showReactionsPop(bubble); });
228
  bubble.addEventListener('pointerdown', startPress);
229
  bubble.addEventListener('pointerup', endPress);
230
  bubble.addEventListener('pointerleave', endPress);
231
-
232
  if(isThem){
233
  let sx=0, dx=0;
234
  bubble.addEventListener('touchstart', (e)=>{ sx = e.touches[0].clientX; dx=0; }, {passive:true});
235
- bubble.addEventListener('touchmove', (e)=>{
236
- dx = e.touches[0].clientX - sx;
237
- if(dx>12) bubble.style.transform = `translateX(${Math.min(dx, 72)}px)`;
238
- }, {passive:true});
239
- bubble.addEventListener('touchend', ()=>{
240
- if(dx>56) startReply(bubble);
241
- bubble.style.transform = '';
242
- });
243
  }
244
  }
245
 
@@ -253,12 +209,8 @@
253
  <img src="avatar_male.png" alt="" class="avatar" onerror="this.style.display='none'">
254
  `;
255
  chat.appendChild(row); scrollBottom();
256
- const b = row.querySelector('.bubble-you');
257
- armBubbleInteractions(b, false);
258
- setTimeout(()=>markDelivered(b, true), 450);
259
- return b;
260
  }
261
-
262
  function bubbleThem(html) {
263
  const row = document.createElement('div'); row.className='row-start';
264
  row.innerHTML = `
@@ -269,41 +221,25 @@
269
  </div>
270
  `;
271
  chat.appendChild(row); scrollBottom();
272
- const b = row.querySelector('.bubble-them');
273
- armBubbleInteractions(b, true);
274
- return b;
275
  }
276
 
277
  function blastBubble({ html, side='you', preview=true, icon='🚫', removeDelay=900 }){
278
- const content = preview
279
- ? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
280
- : `<span class="unsafe-icon" aria-hidden="true">${icon}</span>`;
281
  const bubble = side === 'you' ? bubbleMe(content) : bubbleThem(content);
282
- bubble.classList.add('bubble-blast');
283
- setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
284
  }
285
 
286
  function setTyping(on){ typing.classList.toggle('d-none', !on); }
287
- input.addEventListener('input', ()=>{
288
- input.style.height='auto';
289
- input.style.height=Math.min(input.scrollHeight, 140)+'px';
290
- });
291
- input.addEventListener('keydown',(e)=>{
292
- if(e.key==='Escape'){ input.value=''; input.style.height='auto'; }
293
- if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendText(); }
294
- });
295
 
296
  const normalizeInputText = (t)=> t.replace(/[’‘]/g,"'").replace(/[“”]/g,'"').replace(/\s+/g,' ').trim();
297
 
298
  async function sendText(){
299
- let t = normalizeInputText(input.value);
300
- if(!t) return;
301
-
302
- if(replyTarget){
303
- const quoted = replyTarget.text ? `> ${replyTarget.text}\n` : '';
304
- t = `${quoted}${t}`;
305
- }
306
-
307
  setTyping(true); btnSend.disabled=true;
308
  try{
309
  const fd = new FormData(); fd.append('text', t);
@@ -315,16 +251,12 @@
315
  showToast('Message blocked as unsafe' + reason);
316
  }
317
  }catch(e){ showToast('Error: '+e.message); }
318
- finally{
319
- input.value=''; input.style.height='auto';
320
- setTyping(false); btnSend.disabled=false;
321
- }
322
  }
323
  const btnSend = document.getElementById('btnSend'); btnSend.onclick = sendText;
324
 
325
- // ----- Image -----
326
- const btnImg = document.getElementById('btnImg');
327
- const fileImg = document.getElementById('fileImg');
328
  btnImg.onclick = ()=> fileImg.click();
329
  fileImg.onchange = async ()=>{ if(fileImg.files[0]) await handleImage(fileImg.files[0]); fileImg.value=''; };
330
 
@@ -333,13 +265,9 @@
333
  try{
334
  const fd = new FormData(); fd.append('file', file);
335
  const j = await fetchJSON(api('api/check_image'), { method: 'POST', body: fd });
336
- if(j.safe){
337
- const url = URL.createObjectURL(file);
338
- bubbleMe(`<img src="${url}" class="chat-image" alt="Sent image">`);
339
- } else {
340
- blastBubble({ html: 'Image blocked', side: 'you', preview: false, icon: '🖼️' });
341
- const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Image blocked as unsafe' + reason);
342
- }
343
  }catch(e){ showToast('Error: '+e.message); }
344
  finally{ setTyping(false); }
345
  }
@@ -347,62 +275,27 @@
347
  // Drag & Drop (image only)
348
  ['dragenter','dragover'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.add('drop'); }, false));
349
  ;['dragleave','drop'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.remove('drop'); }, false));
350
- document.addEventListener('drop', async (e)=>{
351
- const f = e.dataTransfer?.files?.[0];
352
- if(!f) return;
353
- if (f.type.startsWith('image/')) await handleImage(f);
354
- else showToast('Only images supported via drop.');
355
- }, false);
356
 
357
- // ----- Voice -----
358
  let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
359
  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')}`; };
360
-
361
- const pickMime = () => {
362
- const prefs=['audio/webm;codecs=opus','audio/webm','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/ogg;codecs=opus','audio/ogg'];
363
- for(const m of prefs) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
364
- return '';
365
- };
366
  async function ensureMic(){ if(!mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({audio:true}); }
367
- function setRecUI(recording){
368
- btnStart.disabled=recording; btnStop.disabled=!recording;
369
- recTimer.classList.toggle('d-none',!recording);
370
- }
371
 
372
- btnStart.onclick = async ()=>{
373
- try{
374
- await ensureMic(); chunks=[];
375
- const mime = pickMime();
376
- mediaRecorder = new MediaRecorder(mediaStream, mime? {mimeType:mime}:{});
377
- mediaRecorder.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
378
- mediaRecorder.onstop = onRecordingStop;
379
- mediaRecorder.start(250);
380
- startTs = Date.now();
381
- tick = setInterval(()=> recTimer.textContent = fmt((Date.now()-startTs)/1000), 300);
382
- setRecUI(true);
383
- setTimeout(()=>{ if(mediaRecorder && mediaRecorder.state==='recording') btnStop.click(); }, 60000);
384
- }catch(e){ showToast('Mic error: '+e.message); }
385
- };
386
- btnStop.onclick = ()=>{
387
- if(mediaRecorder && mediaRecorder.state==='recording'){ mediaRecorder.stop(); }
388
- if(tick){ clearInterval(tick); tick=null; }
389
- setRecUI(false);
390
- };
391
 
392
  async function onRecordingStop(){
393
  try{
394
  setTyping(true);
395
- const type = mediaRecorder?.mimeType || 'audio/webm';
396
- const blob = new Blob(chunks, { type });
397
- const fd = new FormData(); fd.append('file', blob, 'voice');
398
  const j = await fetchJSON(api('api/check_audio'), { method:'POST', body: fd });
399
- if(j.safe){
400
- const url = URL.createObjectURL(blob);
401
- bubbleMe(`<audio controls src="${url}" class="audio-ios"></audio>`);
402
- }else{
403
- blastBubble({ html: 'Voice note blocked', side: 'you', preview: false, icon: '🔊' });
404
- const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Voice note blocked as unsafe' + reason);
405
- }
406
  }catch(e){ showToast('Error: '+e.message); }
407
  finally{ setTyping(false); }
408
  }
 
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
  <title>BubbleGuard – Safe Chat</title>
7
 
 
 
 
8
  <link rel="icon" href="logo.png">
9
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
10
  <link href="styles.css" rel="stylesheet">
 
79
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
80
 
81
  <script>
82
+ // ---- Hard-wire your Space origin (change if you rename it) ----
83
+ const SPACE_ORIGIN = "https://metimiester-bubbleguard.hf.space"; // <<— EDIT if org/space changes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  const api = (p) => `${SPACE_ORIGIN}/${String(p).replace(/^\/+/, '')}`;
85
 
86
  async function fetchJSON(url, opts) {
 
93
  return body;
94
  }
95
 
96
+ // ---- Theme ----
97
  const setTheme = (t)=> document.documentElement.setAttribute('data-bs-theme', t);
98
  const saved = localStorage.getItem('bg-theme');
99
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
 
104
  setTheme(next); localStorage.setItem('bg-theme', next);
105
  };
106
 
107
+ // ---- Health ----
108
  (async () => {
109
  const el = document.getElementById('health');
110
  try {
 
113
  el.textContent = `Online · ${j.device} · T=${t.TEXT_UNSAFE_THR ?? '-'} · S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
114
  } catch (e) {
115
  console.warn('health error:', e);
116
+ el.textContent = 'Offline – ' + (e.message || e);
117
  }
118
  })();
119
 
120
+ // ---- Helpers ----
121
  const $ = (id)=>document.getElementById(id);
122
  const chat = $('chat'), input=$('input'), typing=$('typing');
123
  const btnSend=$('btnSend'), btnImg=$('btnImg'), fileImg=$('fileImg');
 
130
  const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
131
  const esc = (s)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
132
 
133
+ // ---- Reactions/Reply (same as before) ----
134
  const REACTIONS = ["👍","❤️","😂","😮","😢"];
135
  let replyTarget = null;
136
 
 
138
  const meta = bubble.querySelector('.meta');
139
  if(!meta) return;
140
  let ticks = meta.querySelector('.ticks');
141
+ if(!ticks){ ticks = document.createElement('span'); ticks.className = 'ticks'; meta.appendChild(ticks); }
 
 
 
 
142
  ticks.innerHTML = double ? '<span class="tick-double"></span>' : '<span class="tick-solo"></span>';
143
  }
 
144
  function showReactionsPop(bubble){
145
  hideReactionsPop();
146
  const pop = document.createElement('div');
147
+ pop.className = 'react-pop'; pop.setAttribute('role','menu');
 
148
  REACTIONS.forEach(e=>{
149
+ const b=document.createElement('button'); b.type='button'; b.textContent=e; b.setAttribute('aria-label',`React ${e}`);
 
150
  b.onclick = (ev)=>{ ev.stopPropagation(); toggleReaction(bubble, e); hideReactionsPop(); };
151
  pop.appendChild(b);
152
  });
 
154
  setTimeout(()=>document.addEventListener('click', hideReactionsPop, { once:true }), 0);
155
  }
156
  function hideReactionsPop(){ document.querySelectorAll('.react-pop').forEach(p=>p.remove()); }
 
157
  function toggleReaction(bubble, emoji){
158
  bubble._reactions = bubble._reactions || new Map();
159
  const meKey = `me:${emoji}`;
 
163
  }
164
  function renderReactions(bubble){
165
  const counts = {};
166
+ (bubble._reactions||new Map()).forEach((v,k)=>{ const em = k.split(':')[1]; counts[em] = (counts[em]||0) + 1; });
 
 
 
167
  let row = bubble.querySelector('.react-row');
168
+ if(!row){ row = document.createElement('div'); row.className='react-row'; bubble.appendChild(row); }
 
 
 
169
  row.innerHTML = '';
170
  Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([em,c])=>{
171
+ const chip = document.createElement('span'); chip.className='react-chip'; chip.innerHTML = `${em} <span class="count">${c}</span>`;
 
 
172
  row.appendChild(chip);
173
  });
174
  if(Object.keys(counts).length===0) row.remove();
175
  }
 
176
  function startReply(bubble){
177
  const textNode = bubble.querySelector('.copy')?.textContent ?? '';
178
  replyTarget = { el: bubble, text: textNode.trim().slice(0, 60) };
179
  $('replySnippet').textContent = replyTarget.text || '(media)';
180
  $('replyBanner').classList.remove('d-none');
181
+ bubble.classList.add('swipe-hint'); setTimeout(()=>bubble.classList.remove('swipe-hint'), 900);
 
182
  }
183
  function cancelReply(){ replyTarget = null; $('replyBanner').classList.add('d-none'); }
184
  $('replyCancel').onclick = cancelReply;
 
187
  let pressTimer = null;
188
  const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
189
  const endPress = ()=>{ clearTimeout(pressTimer); };
 
190
  bubble.addEventListener('contextmenu', (e)=>{ e.preventDefault(); showReactionsPop(bubble); });
191
  bubble.addEventListener('pointerdown', startPress);
192
  bubble.addEventListener('pointerup', endPress);
193
  bubble.addEventListener('pointerleave', endPress);
 
194
  if(isThem){
195
  let sx=0, dx=0;
196
  bubble.addEventListener('touchstart', (e)=>{ sx = e.touches[0].clientX; dx=0; }, {passive:true});
197
+ bubble.addEventListener('touchmove', (e)=>{ dx = e.touches[0].clientX - sx; if(dx>12) bubble.style.transform = `translateX(${Math.min(dx, 72)}px)`; }, {passive:true});
198
+ bubble.addEventListener('touchend', ()=>{ if(dx>56) startReply(bubble); bubble.style.transform = ''; });
 
 
 
 
 
 
199
  }
200
  }
201
 
 
209
  <img src="avatar_male.png" alt="" class="avatar" onerror="this.style.display='none'">
210
  `;
211
  chat.appendChild(row); scrollBottom();
212
+ const b = row.querySelector('.bubble-you'); armBubbleInteractions(b, false); setTimeout(()=>markDelivered(b, true), 450); return b;
 
 
 
213
  }
 
214
  function bubbleThem(html) {
215
  const row = document.createElement('div'); row.className='row-start';
216
  row.innerHTML = `
 
221
  </div>
222
  `;
223
  chat.appendChild(row); scrollBottom();
224
+ const b = row.querySelector('.bubble-them'); armBubbleInteractions(b, true); return b;
 
 
225
  }
226
 
227
  function blastBubble({ html, side='you', preview=true, icon='🚫', removeDelay=900 }){
228
+ const content = preview ? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
229
+ : `<span class="unsafe-icon" aria-hidden="true">${icon}</span>`;
 
230
  const bubble = side === 'you' ? bubbleMe(content) : bubbleThem(content);
231
+ bubble.classList.add('bubble-blast'); setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
 
232
  }
233
 
234
  function setTyping(on){ typing.classList.toggle('d-none', !on); }
235
+ input.addEventListener('input', ()=>{ input.style.height='auto'; input.style.height=Math.min(input.scrollHeight, 140)+'px'; });
236
+ input.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ input.value=''; input.style.height='auto'; } if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); sendText(); } });
 
 
 
 
 
 
237
 
238
  const normalizeInputText = (t)=> t.replace(/[’‘]/g,"'").replace(/[“”]/g,'"').replace(/\s+/g,' ').trim();
239
 
240
  async function sendText(){
241
+ let t = normalizeInputText(input.value); if(!t) return;
242
+ if(replyTarget){ const quoted = replyTarget.text ? `> ${replyTarget.text}\n` : ''; t = `${quoted}${t}`; }
 
 
 
 
 
 
243
  setTyping(true); btnSend.disabled=true;
244
  try{
245
  const fd = new FormData(); fd.append('text', t);
 
251
  showToast('Message blocked as unsafe' + reason);
252
  }
253
  }catch(e){ showToast('Error: '+e.message); }
254
+ finally{ input.value=''; input.style.height='auto'; setTyping(false); btnSend.disabled=false; }
 
 
 
255
  }
256
  const btnSend = document.getElementById('btnSend'); btnSend.onclick = sendText;
257
 
258
+ // ---- Image ----
259
+ const btnImg = document.getElementById('btnImg'); const fileImg = document.getElementById('fileImg');
 
260
  btnImg.onclick = ()=> fileImg.click();
261
  fileImg.onchange = async ()=>{ if(fileImg.files[0]) await handleImage(fileImg.files[0]); fileImg.value=''; };
262
 
 
265
  try{
266
  const fd = new FormData(); fd.append('file', file);
267
  const j = await fetchJSON(api('api/check_image'), { method: 'POST', body: fd });
268
+ if(j.safe){ const url = URL.createObjectURL(file); bubbleMe(`<img src="${url}" class="chat-image" alt="Sent image">`); }
269
+ else { blastBubble({ html: 'Image blocked', side: 'you', preview: false, icon: '🖼️' });
270
+ const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Image blocked as unsafe' + reason); }
 
 
 
 
271
  }catch(e){ showToast('Error: '+e.message); }
272
  finally{ setTyping(false); }
273
  }
 
275
  // Drag & Drop (image only)
276
  ['dragenter','dragover'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.add('drop'); }, false));
277
  ;['dragleave','drop'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.remove('drop'); }, false));
278
+ 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);
 
 
 
 
 
279
 
280
+ // ---- Voice ----
281
  let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
282
  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')}`; };
283
+ 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 ''; };
 
 
 
 
 
284
  async function ensureMic(){ if(!mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({audio:true}); }
285
+ function setRecUI(r){ btnStart.disabled=r; btnStop.disabled=!r; recTimer.classList.toggle('d-none',!r); }
 
 
 
286
 
287
+ const btnStart=document.getElementById('btnStart'); const btnStop=document.getElementById('btnStop'); const recTimer=document.getElementById('recTimer');
288
+ 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); } };
289
+ btnStop.onclick = ()=>{ if(mediaRecorder && mediaRecorder.state==='recording'){ mediaRecorder.stop(); } if(tick){ clearInterval(tick); tick=null; } setRecUI(false); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  async function onRecordingStop(){
292
  try{
293
  setTyping(true);
294
+ const type=mediaRecorder?.mimeType||'audio/webm'; const blob=new Blob(chunks,{type}); const fd=new FormData(); fd.append('file', blob, 'voice');
 
 
295
  const j = await fetchJSON(api('api/check_audio'), { method:'POST', body: fd });
296
+ if(j.safe){ const url=URL.createObjectURL(blob); bubbleMe(`<audio controls src="${url}" class="audio-ios"></audio>`); }
297
+ else { blastBubble({ html: 'Voice note blocked', side: 'you', preview: false, icon: '🔊' });
298
+ const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Voice note blocked as unsafe' + reason); }
 
 
 
 
299
  }catch(e){ showToast('Error: '+e.message); }
300
  finally{ setTyping(false); }
301
  }