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:
@@ -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
@@ -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 aren’t 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)+'”.':'Couldn’t 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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user