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
+5
View File
@@ -38,6 +38,10 @@ const SMTP_FROM = process.env.SMTP_FROM || (SMTP_USER ? ('Biz Connect <' + SMTP_
const SMTP_ENABLED = !!(SMTP_HOST && SMTP_USER && SMTP_PASS); const SMTP_ENABLED = !!(SMTP_HOST && SMTP_USER && SMTP_PASS);
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.com').replace(/\/+$/, ''); const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.com').replace(/\/+$/, '');
// GIPHY GIF search (#5). Key is read from the server env only and never sent to the browser — the client
// calls our /api/gifs proxy. GIF picker is hidden when this isn't configured.
const GIPHY_API_KEY = process.env.GIPHY_API_KEY || '';
module.exports = { module.exports = {
PORT: process.env.PORT || 8090, PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443, HTTPS_PORT: process.env.HTTPS_PORT || 8443,
@@ -53,6 +57,7 @@ module.exports = {
SMTP_FROM, SMTP_FROM,
SMTP_ENABLED, SMTP_ENABLED,
PUBLIC_BASE_URL, PUBLIC_BASE_URL,
GIPHY_API_KEY,
PUBLIC_DIR, PUBLIC_DIR,
REC_DIR, REC_DIR,
TRANS_DIR, TRANS_DIR,
+65 -8
View File
@@ -605,6 +605,24 @@
.emoji-tabs{display:flex;border-bottom:1px solid var(--line);flex:0 0 auto;} .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{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);} .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{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{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);} .emoji-grid button:hover{background:var(--blue-soft);}
@@ -1058,7 +1076,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <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. // 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 // 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); const draft=getDraft(it.kind,it.id);
if(draft && !active){ return '<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1) 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>'; } 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) 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 '<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(prevBody); }
return pEsc(it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet')); return pEsc(prevBody?((it.last_from_me?'You: ':'')+prevBody):(isG?((it.members||0)+' members'):'No messages yet'));
} }
function rowHTML(it){ function rowHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id; 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(); if(pop && !pop.contains(e.target) && !(btn&&btn.contains(e.target)) && !e.target.closest('.react-btn')) closeEmoji();
}); });
let emojiOpen=false; 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. // Anchor the picker at its trigger: above the composer emoji icon, or at the message you reacted to.
function positionEmojiPop(pop, anchorEl){ function positionEmojiPop(pop, anchorEl){
const parent=pop.offsetParent || pop.parentElement; if(!parent){ return; } 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'; 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; } 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){ 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 // 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. // 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'); 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.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 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 onEmojiPick(e){ if(emojiMode && emojiMode.react){ reactToMessage(emojiMode.react, e); closeEmoji(); } else { insertEmoji(e); } }
function openEmojiForReact(messageId, anchorEl){ openEmoji({ react: messageId }, anchorEl); } 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)); const fmtSeg=(line)=>fmtInline(fmtMentions(line));
// Render a message body: lists (-, *, •, 1.) + inline Markdown + mentions, newlines as <br>. // 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){ 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'); const lines=pEsc(m.body||'').split('\n');
let out='', list=null; const buf=[], para=[]; 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; } }; 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. // back to an in-page popup so hidden-tab users still get alerted.
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden); if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden);
if(wantPopup && !(document.hidden && pushActive)){ 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). // 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); 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); else notify((m.fromName||'New message'), prev, kind, rid);
+39 -1
View File
@@ -113,7 +113,20 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence'); const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls'); const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL } = require('./config'); const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY } = require('./config');
const https = require('https');
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
function fetchJSON(url) {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
if (res.statusCode !== 200) { res.resume(); return reject(new Error('upstream ' + res.statusCode)); }
let buf = ''; res.on('data', (c) => { buf += c; if (buf.length > 4 * 1024 * 1024) { req.destroy(); reject(new Error('too large')); } });
res.on('end', () => { try { resolve(JSON.parse(buf)); } catch (e) { reject(e); } });
});
req.on('error', reject);
req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
});
}
const mailer = require('./mailer'); const mailer = require('./mailer');
// Basic email validation for external meeting invitees (#4). // Basic email validation for external meeting invitees (#4).
const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
@@ -955,6 +968,31 @@ route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' }); json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
}); });
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
route('GET', '/api/gifs', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (!GIPHY_API_KEY) return json(res, 200, { enabled: false, gifs: [] });
const p = new URLSearchParams(req.url.split('?')[1] || '');
const q = (p.get('q') || '').trim();
const offset = Math.max(0, Math.min(200, Number(p.get('offset')) || 0));
const limit = 24;
const base = 'https://api.giphy.com/v1/gifs/' + (q ? 'search' : 'trending');
const url = base + '?api_key=' + encodeURIComponent(GIPHY_API_KEY)
+ (q ? ('&q=' + encodeURIComponent(q)) : '')
+ '&limit=' + limit + '&offset=' + offset + '&rating=pg-13&bundle=messaging_non_clips';
try {
const data = await fetchJSON(url);
const gifs = (data && Array.isArray(data.data) ? data.data : []).map((g) => {
const im = g.images || {};
const full = (im.downsized_medium || im.fixed_height || im.original || {});
const prev = (im.fixed_width_small || im.fixed_height_small || im.preview_gif || full || {});
return { id: g.id, url: full.url || '', preview: prev.url || full.url || '', w: +full.width || 0, h: +full.height || 0, title: g.title || 'GIF' };
}).filter((g) => g.url);
json(res, 200, { enabled: true, gifs, offset: offset + limit });
} catch (e) { json(res, 502, { error: 'gif search failed' }); }
});
// The web build currently on the server (home.html's __BUILD marker). Long-running clients poll this and // The web build currently on the server (home.html's __BUILD marker). Long-running clients poll this and
// offer a Refresh when it changes. This matters because the desktop app now CLOSES TO TRAY — it can run // offer a Refresh when it changes. This matters because the desktop app now CLOSES TO TRAY — it can run
// for weeks without ever reloading the page, so it would silently keep serving stale code after a deploy. // for weeks without ever reloading the page, so it would silently keep serving stale code after a deploy.