feat(chat): GIF picker via server-proxied GIPHY (#5) (batch87)

- Server: GET /api/gifs proxies GIPHY search/trending. The API key is read from the server
  env only (config.GIPHY_API_KEY, from the gitignored .env) and NEVER reaches the browser;
  the picker is hidden when it isn't configured.
- Emoji picker gains a GIF tab (separated from the emoji categories) with a search box + a 2-col
  grid, "Powered by GIPHY" attribution. Clicking a GIF sends it immediately.
- GIFs are HOTLINKED to GIPHY's CDN (their terms require this — no re-hosting): the message body
  is the GIF url, and a body that is a lone GIF url renders inline as the animated GIF (reusing
  the image/lightbox path). Sidebar previews + notifications show "🎞️ GIF", not the raw url.

Key is NOT in git — set as GIPHY_API_KEY in the server .env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:30:44 +05:30
parent c3dc47a94c
commit 02075fbd47
3 changed files with 109 additions and 9 deletions
+65 -8
View File
@@ -605,6 +605,24 @@
.emoji-tabs{display:flex;border-bottom:1px solid var(--line);flex:0 0 auto;}
.emoji-tabs button{flex:1;border:none;background:transparent;font-size:1.05rem;padding:.35rem 0;cursor:pointer;opacity:.55;}
.emoji-tabs button.active{opacity:1;background:var(--blue-soft);}
/* #5 GIF tab: separated from the emoji category tabs */
.emoji-tabs .et-sep{width:1px;align-self:stretch;margin:.3rem .15rem;background:var(--line);flex:0 0 auto;}
.emoji-tabs button[data-gif]{flex:0 0 auto;font-size:.7rem;font-weight:800;letter-spacing:.02em;padding:.35rem .5rem;color:var(--blue);opacity:.7;}
.emoji-tabs button[data-gif].active{opacity:1;}
.gif-wrap{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;}
.gif-search{position:relative;padding:.5rem;flex:0 0 auto;}
.gif-search input{width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:.5rem 2rem .5rem .6rem;font:inherit;font-size:.85rem;background:#fbfcfe;color:var(--ink);}
.gif-search input:focus{outline:none;border-color:var(--blue);}
.gif-search svg{position:absolute;right:1.1rem;top:50%;transform:translateY(-50%);color:var(--muted);pointer-events:none;}
.gif-grid{flex:1 1 auto;overflow-y:auto;display:grid;grid-template-columns:1fr 1fr;gap:.35rem;padding:0 .5rem .3rem;align-content:start;}
.gif-cell{border:none;background:#f1f5f9;border-radius:9px;overflow:hidden;cursor:pointer;padding:0;aspect-ratio:1;}
.gif-cell img{width:100%;height:100%;object-fit:cover;display:block;}
.gif-cell:hover{outline:2px solid var(--blue);}
.gif-load{grid-column:1/-1;display:grid;place-items:center;padding:1.4rem;}
.gif-load img{animation:ptrspin .8s linear infinite;}
.gif-empty{grid-column:1/-1;text-align:center;color:var(--muted);font-size:.82rem;padding:1.4rem .5rem;}
.gif-attr{flex:0 0 auto;text-align:center;font-size:.6rem;color:var(--muted);letter-spacing:.03em;padding:.15rem 0 .35rem;}
.bubble .msg-gif{max-width:230px;max-height:230px;border-radius:12px;cursor:pointer;}
.emoji-grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(8,1fr);gap:.1rem;padding:.4rem;align-content:start;}
.emoji-grid button{border:none;background:transparent;font-size:1.25rem;cursor:pointer;padding:.2rem;border-radius:6px;line-height:1.15;}
.emoji-grid button:hover{background:var(--blue-soft);}
@@ -1058,7 +1076,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-15-batch86';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-15-batch87';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -1618,9 +1636,10 @@ function rowPreviewHTML(it){
const draft=getDraft(it.kind,it.id);
if(draft && !active){ return '<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1)
if(it.callActive){ return '<span class="call-on">'+ic('phone',12)+' Ongoing call</span>'; }
const prevBody=isGifUrl(it.last_body)?'🎞️ GIF':it.last_body; // #5: don't show the raw GIF URL
if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3)
return '<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(it.last_body); }
return pEsc(it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet'));
return '<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(prevBody); }
return pEsc(prevBody?((it.last_from_me?'You: ':'')+prevBody):(isG?((it.members||0)+' members'):'No messages yet'));
}
function rowHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
@@ -2067,7 +2086,7 @@ document.addEventListener('click',(e)=>{
if(pop && !pop.contains(e.target) && !(btn&&btn.contains(e.target)) && !e.target.closest('.react-btn')) closeEmoji();
});
let emojiOpen=false;
function openEmoji(mode, anchorEl){ emojiMode=mode||'compose'; const pop=document.getElementById('emojiPop'); if(!pop) return; emojiOpen=true; renderEmojiPop(pop); pop.style.display='flex'; positionEmojiPop(pop, anchorEl); }
function openEmoji(mode, anchorEl){ emojiMode=mode||'compose'; if(emojiMode&&emojiMode.react&&emojiCat==='gif') emojiCat=0; /* GIFs can't be a reaction */ const pop=document.getElementById('emojiPop'); if(!pop) return; emojiOpen=true; renderEmojiPop(pop); pop.style.display='flex'; positionEmojiPop(pop, anchorEl); }
// Anchor the picker at its trigger: above the composer emoji icon, or at the message you reacted to.
function positionEmojiPop(pop, anchorEl){
const parent=pop.offsetParent || pop.parentElement; if(!parent){ return; }
@@ -2080,15 +2099,49 @@ function positionEmojiPop(pop, anchorEl){
pop.style.left=left+'px'; pop.style.top=top+'px'; pop.style.bottom='auto';
}
function closeEmoji(){ const pop=document.getElementById('emojiPop'); if(pop) pop.style.display='none'; emojiOpen=false; }
let _gifOn=null; // null=unknown, true/false once probed
function renderEmojiPop(pop){
pop.innerHTML='<div class="emoji-tabs">'+EMOJI_CATS.map((c,i)=>'<button type="button" data-i="'+i+'" class="'+(i===emojiCat?'active':'')+'">'+c.icon+'</button>').join('')+'</div><div class="emoji-grid" id="emojiGrid"></div>';
// Tabs: emoji categories + (when configured) a GIF tab, visually separated (#5).
const showGif=(_gifOn!==false) && !(emojiMode&&emojiMode.react); // GIFs make sense only when composing, not reacting
const gifTab='<span class="et-sep"></span><button type="button" data-gif="1" class="'+(emojiCat==='gif'?'active':'')+'" title="GIFs">GIF</button>';
pop.innerHTML='<div class="emoji-tabs">'+EMOJI_CATS.map((c,i)=>'<button type="button" data-i="'+i+'" class="'+(emojiCat===i?'active':'')+'">'+c.icon+'</button>').join('')+(showGif?gifTab:'')+'</div>'
+(emojiCat==='gif'
? '<div class="gif-wrap"><div class="gif-search"><input id="gifQ" placeholder="Search GIFs…" autocomplete="off">'+ic('search',14)+'</div><div class="gif-grid" id="gifGrid"></div><div class="gif-attr">Powered by GIPHY</div></div>'
: '<div class="emoji-grid" id="emojiGrid"></div>');
// stopPropagation: switching tabs re-renders the pop (detaching the clicked button), which would
// make the outside-click handler think the click was outside and close the picker.
pop.querySelectorAll('.emoji-tabs button').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); emojiCat=+b.dataset.i; renderEmojiPop(pop); });
pop.querySelectorAll('.emoji-tabs button[data-i]').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); emojiCat=+b.dataset.i; renderEmojiPop(pop); });
const gt=pop.querySelector('.emoji-tabs button[data-gif]'); if(gt) gt.onclick=(ev)=>{ ev.stopPropagation(); emojiCat='gif'; renderEmojiPop(pop); };
if(emojiCat==='gif'){
const q=pop.querySelector('#gifQ');
if(q){ q.oninput=()=>{ clearTimeout(_gifT); _gifT=setTimeout(()=>loadGifs(q.value.trim()),300); }; q.onclick=(e)=>e.stopPropagation(); setTimeout(()=>q.focus(),0); }
loadGifs('');
return;
}
const grid=pop.querySelector('#emojiGrid');
grid.innerHTML=EMOJI_CATS[emojiCat].list.trim().split(/\s+/).map(e=>'<button type="button" data-emoji="'+e+'">'+e+'</button>').join('');
grid.querySelectorAll('button').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); onEmojiPick(b.dataset.emoji); }); // data-emoji survives twemoji's <img> swap
twemojify(pop); // modern emoji images in the picker
}
let _gifT=null, _gifSeq=0;
async function loadGifs(q){
const grid=document.getElementById('gifGrid'); if(!grid) return;
const seq=++_gifSeq; grid.innerHTML='<div class="gif-load"><img src="/loaders/loader-ring.svg" width="26" height="26" alt=""></div>';
let d; try{ d=await fetch('/api/gifs?q='+encodeURIComponent(q||'')).then(r=>r.json()); }catch(_){ d=null; }
if(seq!==_gifSeq) return;
if(d && d.enabled===false){ _gifOn=false; grid.innerHTML='<div class="gif-empty">GIFs arent configured.</div>'; return; }
_gifOn=true;
const gifs=(d&&d.gifs)||[];
if(!gifs.length){ grid.innerHTML='<div class="gif-empty">'+(q?'No GIFs for “'+pEsc(q)+'”.':'Couldnt load GIFs.')+'</div>'; return; }
grid.innerHTML=gifs.map(g=>'<button type="button" class="gif-cell" data-url="'+pEsc(g.url)+'" title="'+pEsc(g.title||'GIF')+'"><img src="'+pEsc(g.preview||g.url)+'" loading="lazy" alt=""></button>').join('');
grid.querySelectorAll('.gif-cell').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); sendGif(b.dataset.url); });
}
// GIFs send immediately (like a sticker), hotlinked to GIPHY's CDN per their terms.
async function sendGif(url){
if(!url || !selected) return;
closeEmoji();
const kind=selected.kind, id=selected.id;
try{ const m=await postJSON('/api/messages', kind==='group'?{group:id, body:url}:{to:id, body:url}); if(m){ /* echo arrives via WS */ } }
catch(e){ toast(e.message||'Could not send GIF'); }
}
function onEmojiPick(e){ if(emojiMode && emojiMode.react){ reactToMessage(emojiMode.react, e); closeEmoji(); } else { insertEmoji(e); } }
function openEmojiForReact(messageId, anchorEl){ openEmoji({ react: messageId }, anchorEl); }
@@ -2759,7 +2812,11 @@ function fmtMentions(s){ // s already HTML-escaped
}
const fmtSeg=(line)=>fmtInline(fmtMentions(line));
// Render a message body: lists (-, *, •, 1.) + inline Markdown + mentions, newlines as <br>.
// #5: a message whose whole body is a GIF media URL (from our GIF picker, or pasted) renders as the GIF.
function isGifUrl(s){ return /^https?:\/\/(media\d*\.giphy\.com\/media\/|.*\.giphy\.com\/.*\/giphy\.gif|c\.tenor\.com\/|media\.tenor\.com\/)/i.test(String(s||'').trim()) || /^https?:\/\/\S+\.gif(\?\S*)?$/i.test(String(s||'').trim()); }
function renderMsgBody(m){
const body=(m.body||'').trim();
if(isGifUrl(body)){ return '<img class="att-img msg-gif" src="'+pEsc(body)+'" data-img="'+pEsc(body)+'" loading="lazy" alt="GIF">'; }
const lines=pEsc(m.body||'').split('\n');
let out='', list=null; const buf=[], para=[];
const flushList=()=>{ if(list){ out+='<'+list+' class="msg-list">'+buf.map(x=>'<li>'+x+'</li>').join('')+'</'+list+'>'; buf.length=0; list=null; } };
@@ -3224,7 +3281,7 @@ function onChatMessage(m){
// back to an in-page popup so hidden-tab users still get alerted.
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden);
if(wantPopup && !(document.hidden && pushActive)){
const prev=m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment';
const prev=isGifUrl(m.body)?'🎞️ GIF':(m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment');
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
if(kind==='group') notify((it&&it.name)||m.groupName||'Group', (m.fromName?m.fromName+': ':'')+prev, kind, rid);
else notify((m.fromName||'New message'), prev, kind, rid);