[READ-ONLY] Mirror of https://github.com/aaronateataco/ermine. A stoat web client erminechat.vercel.app
0

Configure Feed

Select the types of activity you want to include in your feed.

Refactor code structure for improved readability and maintainability

Fascinating Pistachio (May 19, 2026, 8:57 PM +0100) cefc4d16 d4a956e3

+48 -18
assets/ermine-logo.png

This is a binary file and will not be displayed.

+48 -18
src/App.jsx
··· 2 2 import React, { useCallback, useEffect, useMemo, useRef, useState, memo, Component } from 'react'; 3 3 import { AlertCircle, BookOpen, Check, ChevronDown, ChevronRight, Copy, Edit2, Hash, Info, Link, Loader, LogOut, Menu, MessageSquare, Paperclip, Pin, Plus, Reply, Save, Search, Send, Settings, Trash2, Users, UserPlus, UserX, X } from 'lucide-react'; 4 4 5 + // ─── ULID generator (stoat uses ulid for message nonces, from Draft.ts) ───── 6 + const ULID_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; 7 + function genUlid() { 8 + const t = Date.now(); 9 + let s = ''; 10 + let tmp = t; 11 + for (let i = 9; i >= 0; i--) { s = ULID_CHARS[tmp % 32] + s; tmp = Math.floor(tmp / 32); } 12 + for (let i = 0; i < 16; i++) s += ULID_CHARS[Math.floor(Math.random() * 32)]; 13 + return s; 14 + } 15 + 5 16 // ─── Design tokens ──────────────────────────────────────────────────────────── 6 17 const T = { rail:'#1e1f22',sidebar:'#2b2d31',chat:'#313338',input:'#383a40',elevated:'#232428',hover:'#35373c',active:'#404249',brand:'#5865f2',brandH:'#4752c4',green:'#3ABF7E',yellow:'#F39F00',red:'#F84848',blue:'#4799F0',grey:'#A5A5A5',t1:'#f2f3f5',t2:'#b5bac1',t3:'#80848e' }; 7 18 const inp = `w-full rounded bg-[#1e1f22] border border-transparent px-3 py-2 text-sm text-[#f2f3f5] placeholder:text-[#80848e] focus:border-[#5865f2] focus:outline-none transition-colors`; ··· 42 53 const clrF=(base,clear=[])=>{if(!clear?.length)return base;const n={...base};clear.forEach(f=>{const k=f?.[0]?.toLowerCase()+f?.slice(1);if(k)delete n[k];});return n;}; 43 54 44 55 // ─── Twemoji ───────────────────────────────────────────────────────────────── 45 - const TW='https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/'; 46 - const toCP=(s)=>{const r=[];let c=0,p=0,i=0;while(i<s.length){c=s.charCodeAt(i++);if(p){r.push((0x10000+((p-0xd800)<<10)+(c-0xdc00)).toString(16));p=0;}else if(0xd800<=c&&c<=0xdbff)p=c;else r.push(c.toString(16));}return r.join('-');}; 47 - const TwImg=memo(({char,cls='inline-block w-5 h-5 align-bottom'})=><img src={`${TW}${toCP(char)}.png`} alt={char} className={cls} draggable={false} onError={e=>{e.currentTarget.style.display='none';}}/>); 56 + // stoat uses its own emoji CDN (from UnicodeEmoji.tsx in stoat source) 57 + const STOAT_EMOJI='https://static.stoat.chat/emoji/fluent-3d/'; 58 + const toCP=(s)=>{const r=[];let c=0,p=0,i=0;while(i<s.length){c=s.charCodeAt(i++);if(p){r.push((0x10000+((p-0xd800)<<10)+(c-0xdc00)).toString(16));p=0;}else if(0xd800<=c&&c<=0xdbff)p=c;else r.push(c.toString(16));}return r.join('-');} 59 + const stoatEmojiUrl=(char)=>`${STOAT_EMOJI}${toCP(char)}.svg?v=1`; 60 + const TwImg=memo(({char,cls='inline-block w-5 h-5 align-bottom'})=><img src={stoatEmojiUrl(char)} alt={char} className={cls} draggable={false} onError={e=>{e.currentTarget.style.display='none';}}/>); 48 61 TwImg.displayName='TwImg'; 49 62 const twR=(text,cls='inline-block w-5 h-5 align-bottom')=>text?text.split(/([\uD800-\uDBFF][\uDC00-\uDFFF])/g).map((p,i)=>/[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(p)?<TwImg key={i} char={p} cls={cls}/>:p):null; 50 63 ··· 186 199 const STD_EMOJI = EMOJI_CATS.flatMap(c=>c.e); 187 200 188 201 // ─── Emoji + GIF Picker ─────────────────────────────────────────────────────── 189 - const GIPHY_KEY = 'dc6zaTOxFJmzC'; 202 + // stoat uses gifbox.me (from GifPicker.tsx + env.ts in stoat source) 203 + const GIFBOX_URL = 'https://api.gifbox.me'; 190 204 191 - const EmojiGifPicker = memo(({ ceById, allCE, cdn, onEmoji, onCE, onGif }) => { 205 + const EmojiGifPicker = memo(({ ceById, allCE, cdn, onEmoji, onCE, onGif, token }) => { 192 206 const [tab, setTab] = useState('emoji'); 193 207 const [eCat, setECat] = useState(0); 194 208 const [q, setQ] = useState(''); ··· 199 213 const debRef = useRef(null); 200 214 201 215 useEffect(()=>{ 216 + if(!token)return; 202 217 setGL(true); 203 - fetch(`https://api.giphy.com/v1/gifs/trending?api_key=${GIPHY_KEY}&limit=24&rating=g`) 204 - .then(r=>r.json()).then(d=>{setTr(d.data||[]);setGL(false);}).catch(()=>setGL(false)); 205 - },[]); 218 + fetch(`${GIFBOX_URL}/trending?locale=en_US&limit=24`,{headers:{'x-session-token':token}}) 219 + .then(r=>r.json()).then(d=>{setTr(d.results||[]);setGL(false);}).catch(()=>setGL(false)); 220 + },[token]); 206 221 207 222 const searchGifs = useCallback((v)=>{ 208 223 clearTimeout(debRef.current); 209 224 if(!v.trim()){setGifs([]);return;} 210 225 setGL(true); 211 226 debRef.current = setTimeout(()=>{ 212 - fetch(`https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_KEY}&q=${encodeURIComponent(v)}&limit=24&rating=g`) 213 - .then(r=>r.json()).then(d=>{setGifs(d.data||[]);setGL(false);}).catch(()=>setGL(false)); 227 + const endpoint = v==='trending' 228 + ? `${GIFBOX_URL}/trending?locale=en_US` 229 + : `${GIFBOX_URL}/search?locale=en_US&query=${encodeURIComponent(v)}`; 230 + fetch(endpoint,{headers:{'x-session-token':token}}) 231 + .then(r=>r.json()).then(d=>{setGifs(d.results||[]);setGL(false);}).catch(()=>setGL(false)); 214 232 },400); 215 - },[]); 233 + },[token]); 216 234 217 235 const filtE = useMemo(()=>{if(!q)return EMOJI_CATS[eCat]?.e||[];const ql=q.toLowerCase();return STD_EMOJI.filter(e=>e.includes(ql));},[q,eCat]); 218 236 const filtCE = useMemo(()=>{if(!q)return allCE;const ql=q.toLowerCase();return allCE.filter(e=>e.name.toLowerCase().includes(ql)||e.serverName?.toLowerCase().includes(ql));},[q,allCE]); ··· 262 280 <> 263 281 {!gifQ&&<p className="text-[10px] font-bold uppercase tracking-widest text-[#80848e] mb-2 px-1">Trending</p>} 264 282 <div className="columns-2 gap-1.5"> 265 - {displayGifs.map(gif=>{const src=gif.images?.fixed_height_small?.url||gif.images?.downsized?.url||gif.images?.original?.url;return src?(<button key={gif.id} className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity" onClick={()=>onGif(gif.images?.original?.url||src)} type="button"><LazyImg src={src} alt={gif.title||'GIF'} className="w-full object-cover"/></button>):null;})} 283 + {displayGifs.map((gif,gi)=>{ 284 + // gifbox format: { url, media_formats: { webm, tinywebm } } 285 + const thumb=gif.media_formats?.tinywebm?.url||gif.url; 286 + const full=gif.url||thumb; 287 + if(!thumb)return null; 288 + return( 289 + <button key={gif.id||gi} className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity" onClick={()=>onGif(full)} type="button"> 290 + <video src={thumb} className="w-full object-cover" autoPlay loop muted playsInline style={{pointerEvents:'none'}}/> 291 + </button> 292 + ); 293 + })} 266 294 </div> 267 - <p className="text-center text-[9px] text-[#4f5660] mt-2 pb-1">Powered by GIPHY</p> 295 + <p className="text-center text-[9px] text-[#4f5660] mt-2 pb-1">Powered by Gifbox</p> 268 296 </> 269 297 )} 270 298 </div> ··· 478 506 useEffect(()=>{setDn(user?.display_name||user?.username||'');setBio(user?.profile?.content||'');},[user]); 479 507 const saveProfile=async()=>{setSaving(true);try{const r=await fetch(`${apiUrl}/users/@me`,{method:'PATCH',headers:{'Content-Type':'application/json','x-session-token':token},body:JSON.stringify({display_name:dn.trim()||undefined,profile:{content:bio.trim()||undefined}})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).description||'Save failed');onUpdate(await r.json());addToast('Profile saved!','success');}catch(e){addToast(e.message,'error');}finally{setSaving(false);};}; 480 508 const changePw=async()=>{if(!pwNew||pwNew!==pwConf){addToast('Passwords do not match','error');return;}setPwS(true);try{const r=await fetch(`${apiUrl}/auth/account/change/password`,{method:'PATCH',headers:{'Content-Type':'application/json','x-session-token':token},body:JSON.stringify({current_password:pwCur,new_password:pwNew})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).description||'Failed');addToast('Password changed!','success');setPwCur('');setPwNew('');setPwConf('');}catch(e){addToast(e.message,'error');}finally{setPwS(false);};}; 481 - const uploadAvatar=async(file)=>{if(!file)return;try{const body=new FormData();body.append('file',file);const ur=await fetch(`${cdn}/avatars`,{method:'POST',body,headers:{'X-Session-Token':token}});if(!ur.ok)throw new Error('Upload failed');const{id}=await ur.json();const r=await fetch(`${apiUrl}/users/@me`,{method:'PATCH',headers:{'Content-Type':'application/json','x-session-token':token},body:JSON.stringify({avatar:id})});if(!r.ok)throw new Error('Failed');onUpdate(await r.json());addToast('Avatar updated!','success');}catch(e){addToast(e.message,'error');};}; 509 + const uploadAvatar=async(file)=>{if(!file)return;try{const body=new FormData();body.append('file',file);const ur=await fetch(`${cdn}/avatars`,{method:'POST',body,headers:{'x-session-token':token}});if(!ur.ok)throw new Error('Upload failed');const{id}=await ur.json();const r=await fetch(`${apiUrl}/users/@me`,{method:'PATCH',headers:{'Content-Type':'application/json','x-session-token':token},body:JSON.stringify({avatar:id})});if(!r.ok)throw new Error('Failed');onUpdate(await r.json());addToast('Avatar updated!','success');}catch(e){addToast(e.message,'error');};}; 482 510 return( 483 511 <div className="flex min-h-[380px]"> 484 512 <nav className="w-36 shrink-0 border-r border-[#1e1f22] p-2 space-y-0.5">{[['profile','Profile'],['account','Account'],['about','About']].map(([id,lbl])=><button key={id} className={`w-full rounded-md px-3 py-2 text-left text-[13px] font-medium transition-colors ${tab===id?'bg-[#404249] text-[#f2f3f5]':'text-[#80848e] hover:bg-[#35373c] hover:text-[#b5bac1]'}`} onClick={()=>setTab(id)}>{lbl}</button>)}</nav> ··· 770 798 logoutRef.current=logout; 771 799 772 800 // ── Messaging ───────────────────────────────────────────────────────────── 773 - const uploadFiles=async(files=[])=>{const ids=[];for(const f of files){const body=new FormData();body.append('file',f);const r=await fetch(`${cfg.cdn}/attachments`,{method:'POST',body,headers:{'X-Session-Token':auth.token}});if(!r.ok)throw new Error(`Upload failed: ${f.name}`);const d=await r.json();if(d?.id)ids.push(d.id);}return ids;}; 801 + const uploadFiles=async(files=[])=>{const ids=[];for(const f of files){const body=new FormData();body.append('file',f);const r=await fetch(`${cfg.cdn}/attachments`,{method:'POST',body,headers:{'x-session-token':auth.token}});if(!r.ok)throw new Error(`Upload failed: ${f.name}`);const d=await r.json();if(d?.id)ids.push(d.id);}return ids;}; 774 802 775 - const sendMessage=async()=>{const content=inputText.trim();const hasFiles=pendingFiles.length>0;if((!content&&!hasFiles)||!selChannel||selChannel==='friends')return;const capFiles=[...pendingFiles];const capReply=replyingTo;setInputText('');setPendingFiles([]);setReplyingTo(null);setShowPicker(false);setIsUploading(hasFiles);sendTypingEnd();const nonce=crypto.randomUUID();const pid=`pending-${nonce}`;const me=users[auth.uid]||{_id:auth.uid,username:'You'};const opt={_id:pid,channel:selChannel,author:auth.uid,user:me,content,createdAt:new Date().toISOString(),reactions:{},replies:capReply?[{id:capReply._id,mention:false}]:undefined};setMessages(p=>{const l=p[selChannel]||[];return{...p,[selChannel]:uniq([...l,opt].slice(-200))};});try{const aids=hasFiles?await uploadFiles(capFiles):[];const payload={content,nonce,replies:capReply?[{id:capReply._id,mention:false}]:undefined,attachments:aids.length?aids:undefined};const r=await fetch(`${cfg.api}/channels/${selChannel}/messages`,{method:'POST',headers:{'Content-Type':'application/json','x-session-token':auth.token},body:JSON.stringify(payload)});if(!r.ok)throw new Error('Send failed');const posted=await r.json();if(posted?._id){upsertFromMsgs([posted]);setMessages(p=>{const l=(p[selChannel]||[]).filter(m=>m._id!==pid);const dd=l.some(m=>m._id===posted._id)?l.map(m=>m._id===posted._id?posted:m):[...l,posted];return{...p,[selChannel]:uniq(dd.sort((a,b)=>a._id>b._id?1:-1))};});}}catch(err){setMessages(p=>({...p,[selChannel]:(p[selChannel]||[]).filter(m=>m._id!==pid)}));setInputText(content);addToast(err.message||'Failed to send','error');}finally{setIsUploading(false);};}; 803 + const sendMessage=async()=>{const content=inputText.trim();const hasFiles=pendingFiles.length>0;if((!content&&!hasFiles)||!selChannel||selChannel==='friends')return;const capFiles=[...pendingFiles];const capReply=replyingTo;setInputText('');setPendingFiles([]);setReplyingTo(null);setShowPicker(false);setIsUploading(hasFiles);sendTypingEnd();const nonce=genUlid();const pid=`pending-${nonce}`;const me=users[auth.uid]||{_id:auth.uid,username:'You'};const opt={_id:pid,channel:selChannel,author:auth.uid,user:me,content,createdAt:new Date().toISOString(),reactions:{},replies:capReply?[{id:capReply._id,mention:false}]:undefined};setMessages(p=>{const l=p[selChannel]||[];return{...p,[selChannel]:uniq([...l,opt].slice(-200))};});try{const aids=hasFiles?await uploadFiles(capFiles):[];// stoat source: omit attachments entirely when empty (not send []) 804 + const payload={content,nonce,replies:capReply?[{id:capReply._id,mention:false}]:undefined}; 805 + if(aids.length)payload.attachments=aids;const r=await fetch(`${cfg.api}/channels/${selChannel}/messages`,{method:'POST',headers:{'Content-Type':'application/json','x-session-token':auth.token},body:JSON.stringify(payload)});if(!r.ok)throw new Error('Send failed');const posted=await r.json();if(posted?._id){upsertFromMsgs([posted]);setMessages(p=>{const l=(p[selChannel]||[]).filter(m=>m._id!==pid);const dd=l.some(m=>m._id===posted._id)?l.map(m=>m._id===posted._id?posted:m):[...l,posted];return{...p,[selChannel]:uniq(dd.sort((a,b)=>a._id>b._id?1:-1))};});}}catch(err){setMessages(p=>({...p,[selChannel]:(p[selChannel]||[]).filter(m=>m._id!==pid)}));setInputText(content);addToast(err.message||'Failed to send','error');}finally{setIsUploading(false);};}; 776 806 777 - const sendGif=async(url)=>{if(!selChannel||selChannel==='friends')return;setShowPicker(false);const nonce=crypto.randomUUID();const pid=`pending-${nonce}`;const me=users[auth.uid]||{_id:auth.uid,username:'You'};setMessages(p=>{const l=p[selChannel]||[];return{...p,[selChannel]:uniq([...l,{_id:pid,channel:selChannel,author:auth.uid,user:me,content:url,createdAt:new Date().toISOString(),reactions:{}}].slice(-200))};});try{const r=await fetch(`${cfg.api}/channels/${selChannel}/messages`,{method:'POST',headers:{'Content-Type':'application/json','x-session-token':auth.token},body:JSON.stringify({content:url,nonce})});if(!r.ok)throw new Error('Send failed');const posted=await r.json();if(posted?._id)setMessages(p=>{const l=(p[selChannel]||[]).filter(m=>m._id!==pid);return{...p,[selChannel]:uniq([...l,posted].sort((a,b)=>a._id>b._id?1:-1))};});}catch{setMessages(p=>({...p,[selChannel]:(p[selChannel]||[]).filter(m=>m._id!==pid)}));}}; 807 + const sendGif=async(url)=>{if(!selChannel||selChannel==='friends')return;setShowPicker(false);const nonce=genUlid();const pid=`pending-${nonce}`;const me=users[auth.uid]||{_id:auth.uid,username:'You'};setMessages(p=>{const l=p[selChannel]||[];return{...p,[selChannel]:uniq([...l,{_id:pid,channel:selChannel,author:auth.uid,user:me,content:url,createdAt:new Date().toISOString(),reactions:{}}].slice(-200))};});try{const r=await fetch(`${cfg.api}/channels/${selChannel}/messages`,{method:'POST',headers:{'Content-Type':'application/json','x-session-token':auth.token},body:JSON.stringify({content:url,nonce})});if(!r.ok)throw new Error('Send failed');const posted=await r.json();if(posted?._id)setMessages(p=>{const l=(p[selChannel]||[]).filter(m=>m._id!==pid);return{...p,[selChannel]:uniq([...l,posted].sort((a,b)=>a._id>b._id?1:-1))};});}catch{setMessages(p=>({...p,[selChannel]:(p[selChannel]||[]).filter(m=>m._id!==pid)}));}}; 778 808 779 809 const editMessage=async(msgId,content)=>{try{await fetch(`${cfg.api}/channels/${selChannel}/messages/${msgId}`,{method:'PATCH',headers:{'Content-Type':'application/json','x-session-token':auth.token},body:JSON.stringify({content})});}catch{addToast('Failed to edit','error');};}; 780 810 const requestDelete=useCallback((msg)=>setDeleteTarget(msg),[]); ··· 938 968 <input className="hidden" multiple onChange={onFilePick} ref={fileInputRef} type="file"/> 939 969 <div className="relative shrink-0"> 940 970 <button className="p-2 text-[#80848e] hover:text-[#b5bac1] disabled:opacity-30 text-xl leading-none" disabled={selChannel==='friends'} onClick={()=>setShowPicker(p=>!p)}>😊</button> 941 - {showPicker&&selChannel!=='friends'&&<div ref={pickerRef} className="absolute bottom-full mb-2 left-0 z-20"><EmojiGifPicker ceById={ceById} allCE={allCE} cdn={cfg.cdn} onEmoji={addEmoji} onCE={addCE} onGif={sendGif}/></div>} 971 + {showPicker&&selChannel!=='friends'&&<div ref={pickerRef} className="absolute bottom-full mb-2 left-0 z-20"><EmojiGifPicker ceById={ceById} allCE={allCE} cdn={cfg.cdn} onEmoji={addEmoji} onCE={addCE} onGif={sendGif} token={auth.token}/></div>} 942 972 </div> 943 973 <textarea ref={composerRef} className={`composer-ta flex-1 px-2 py-1.5 ${selChannel==='friends'?'opacity-50 cursor-not-allowed':''}`} rows={1} disabled={selChannel==='friends'} value={inputText} 944 974 onPaste={onPaste}