[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.

Refine status/settings UX and add privacy policy/cookie session storage

Fascinating Pistachio (Feb 14, 2026, 4:39 PM UTC) 7324ea0c ecd2dc15

+169 -20
+7
README.md
··· 56 56 - `VITE_STOAT_CDN_URL` 57 57 58 58 The app will use those values at build time, while still allowing manual overrides from **Advanced connection settings** on the login screen. 59 + 60 + ## Privacy and GDPR direction 61 + 62 + - Ermine stores session details in browser cookies (`ermine_session_token`, `ermine_user_id`, `ermine_api_url`) for authentication continuity. 63 + - Ermine does not persist personal user data on Ermine repositories or Ermine-hosted servers. 64 + - Network communication is limited to configured official stoat.chat endpoints. 65 + - See `privacy-policy.html` for the user-facing privacy notice.
+42
privacy-policy.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Ermine Privacy Policy</title> 7 + <style> 8 + body { background:#1e1f22; color:#f2f3f5; font-family: Inter, system-ui, sans-serif; margin:0; } 9 + main { max-width: 760px; margin: 0 auto; padding: 48px 20px; } 10 + h1,h2 { color:#fff; } 11 + section { background:#2b2d31; border:1px solid #202225; border-radius:12px; padding:20px; margin-top:16px; } 12 + a { color:#8ea1ff; } 13 + p,li { color:#c8cbd1; line-height:1.5; } 14 + </style> 15 + </head> 16 + <body> 17 + <main> 18 + <h1>Ermine Privacy Policy</h1> 19 + <p>Last updated: 2026-02-14</p> 20 + 21 + <section> 22 + <h2>What Ermine stores</h2> 23 + <p>Ermine stores your session token, user ID, and selected API URL in browser cookies so you can stay signed in.</p> 24 + </section> 25 + 26 + <section> 27 + <h2>What Ermine does not store</h2> 28 + <p>Ermine does not store your personal chat data, profile content, or server data on Ermine repositories or Ermine-operated servers.</p> 29 + </section> 30 + 31 + <section> 32 + <h2>Service endpoints</h2> 33 + <p>Ermine communicates with official stoat.chat services (API, websocket, and CDN endpoints configured in the app).</p> 34 + </section> 35 + 36 + <section> 37 + <h2>Contact</h2> 38 + <p>If you have privacy questions, contact the Ermine maintainers through the project repository.</p> 39 + </section> 40 + </main> 41 + </body> 42 + </html>
+120 -20
src/App.jsx
··· 2 2 import { 3 3 Activity, 4 4 AlertCircle, 5 + Circle, 6 + EyeOff, 5 7 Hash, 6 8 LogOut, 7 9 MessageSquare, 10 + Moon, 11 + MinusCircle, 8 12 Plus, 9 13 Paperclip, 10 14 Reply, ··· 32 36 33 37 const inputBase = 34 38 'w-full rounded-md border border-[#202225] bg-[#111214] px-3 py-2 text-sm text-gray-100 placeholder:text-gray-500 focus:border-[#5865f2] focus:outline-none focus:ring-1 focus:ring-[#5865f2]'; 39 + 40 + const TOKEN_COOKIE_NAME = 'ermine_session_token'; 41 + const USER_COOKIE_NAME = 'ermine_user_id'; 42 + const API_COOKIE_NAME = 'ermine_api_url'; 43 + 44 + const getCookie = (name) => { 45 + if (typeof document === 'undefined') return null; 46 + const value = document.cookie 47 + .split('; ') 48 + .find((entry) => entry.startsWith(`${name}=`)); 49 + if (!value) return null; 50 + return decodeURIComponent(value.slice(name.length + 1)); 51 + }; 52 + 53 + const setCookie = (name, value, maxAgeSeconds = 60 * 60 * 24 * 30) => { 54 + if (typeof document === 'undefined') return; 55 + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax; Secure`; 56 + }; 57 + 58 + const clearCookie = (name) => { 59 + if (typeof document === 'undefined') return; 60 + document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax; Secure`; 61 + }; 62 + 63 + const STATUS_OPTIONS = [ 64 + { value: 'Online', label: 'Online', icon: Activity, iconClass: 'text-[#3ba55d]' }, 65 + { value: 'Idle', label: 'Idle', icon: Moon, iconClass: 'text-[#f0b232]' }, 66 + { value: 'Busy', label: 'Do Not Disturb', icon: MinusCircle, iconClass: 'text-[#ed4245]' }, 67 + { value: 'Focus', label: 'Focus', icon: Circle, iconClass: 'text-[#4f7dff]' }, 68 + { value: 'Invisible', label: 'Invisible', icon: EyeOff, iconClass: 'text-gray-400' }, 69 + ]; 35 70 36 71 const toTime = (value) => { 37 72 const date = value ? new Date(value) : new Date(); ··· 553 588 const [statusDraft, setStatusDraft] = useState({ presence: 'Online', text: '' }); 554 589 const [isSavingStatus, setIsSavingStatus] = useState(false); 555 590 const [isAccountHovered, setIsAccountHovered] = useState(false); 591 + const [privacyAcknowledged, setPrivacyAcknowledged] = useState(false); 556 592 557 593 const [loginMode, setLoginMode] = useState('credentials'); 558 594 const [email, setEmail] = useState(''); ··· 561 597 const [manualToken, setManualToken] = useState(''); 562 598 const [loginError, setLoginError] = useState(''); 563 599 const [isLoggingIn, setIsLoggingIn] = useState(false); 600 + const [privacyConsent, setPrivacyConsent] = useState(false); 564 601 const [wsReconnectAttempt, setWsReconnectAttempt] = useState(0); 565 602 566 603 const wsRef = useRef(null); ··· 578 615 const replyMessageCacheRef = useRef({}); 579 616 const fileInputRef = useRef(null); 580 617 const autoFollowRef = useRef(true); 618 + const isProgrammaticScrollRef = useRef(false); 581 619 582 620 const serverList = useMemo(() => Object.values(servers), [servers]); 583 621 ··· 816 854 }; 817 855 818 856 useEffect(() => { 819 - const savedToken = localStorage.getItem('stoat_token'); 820 - const savedUserId = localStorage.getItem('stoat_user_id'); 821 - const savedApi = localStorage.getItem('stoat_api_url'); 857 + const savedToken = getCookie(TOKEN_COOKIE_NAME) || localStorage.getItem('stoat_token'); 858 + const savedUserId = getCookie(USER_COOKIE_NAME) || localStorage.getItem('stoat_user_id'); 859 + const savedApi = getCookie(API_COOKIE_NAME) || localStorage.getItem('stoat_api_url'); 822 860 823 861 const api = savedApi || DEFAULT_API_URL; 824 862 setConfig((prev) => ({ ...prev, apiUrl: api })); ··· 1054 1092 autoFollowRef.current = true; 1055 1093 1056 1094 const handleScroll = () => { 1095 + if (isProgrammaticScrollRef.current) return; 1057 1096 const nearBottom = isNearBottom(); 1058 1097 autoFollowRef.current = nearBottom; 1059 - setShowGoLatest(!nearBottom); 1098 + setShowGoLatest((prev) => (prev === !nearBottom ? prev : !nearBottom)); 1060 1099 }; 1061 1100 1062 1101 container.addEventListener('scroll', handleScroll); ··· 1065 1104 1066 1105 useEffect(() => { 1067 1106 if (autoFollowRef.current || isNearBottom()) { 1068 - messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1107 + isProgrammaticScrollRef.current = true; 1108 + messagesBottomRef.current?.scrollIntoView({ behavior: 'auto' }); 1109 + requestAnimationFrame(() => { 1110 + isProgrammaticScrollRef.current = false; 1111 + }); 1069 1112 autoFollowRef.current = true; 1070 1113 setShowGoLatest(false); 1071 1114 return; ··· 1333 1376 1334 1377 1335 1378 const goToLatest = () => { 1379 + isProgrammaticScrollRef.current = true; 1336 1380 autoFollowRef.current = true; 1337 1381 messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1382 + requestAnimationFrame(() => { 1383 + isProgrammaticScrollRef.current = false; 1384 + }); 1338 1385 setShowGoLatest(false); 1339 1386 }; 1340 1387 ··· 1394 1441 const handleLogin = async (event) => { 1395 1442 event.preventDefault(); 1396 1443 setLoginError(''); 1444 + if (!privacyConsent) { 1445 + setLoginError('Please accept the privacy policy before signing in.'); 1446 + return; 1447 + } 1397 1448 setIsLoggingIn(true); 1398 1449 1399 1450 try { ··· 1428 1479 1429 1480 if (!token || !userId) throw new Error('Session creation failed.'); 1430 1481 1431 - localStorage.setItem('stoat_token', token); 1432 - localStorage.setItem('stoat_user_id', userId); 1433 - localStorage.setItem('stoat_api_url', config.apiUrl); 1482 + setCookie(TOKEN_COOKIE_NAME, token); 1483 + setCookie(USER_COOKIE_NAME, userId); 1484 + setCookie(API_COOKIE_NAME, config.apiUrl); 1434 1485 1435 1486 setAuth({ token, userId }); 1436 1487 setView('app'); ··· 1442 1493 }; 1443 1494 1444 1495 const logout = () => { 1496 + clearCookie(TOKEN_COOKIE_NAME); 1497 + clearCookie(USER_COOKIE_NAME); 1498 + clearCookie(API_COOKIE_NAME); 1445 1499 localStorage.removeItem('stoat_token'); 1446 1500 localStorage.removeItem('stoat_user_id'); 1501 + localStorage.removeItem('stoat_api_url'); 1447 1502 setAuth({ token: null, userId: null }); 1448 1503 setMessages({}); 1449 1504 setServers({}); ··· 1719 1774 /> 1720 1775 )} 1721 1776 1777 + <label className="flex items-start gap-2 rounded-md border border-[#2f3237] bg-[#1e1f22] p-2 text-xs text-gray-300"> 1778 + <input checked={privacyConsent} className="mt-0.5" onChange={(e) => setPrivacyConsent(e.target.checked)} type="checkbox" /> 1779 + <span> 1780 + I agree to the <a className="text-[#8ea1ff] underline hover:text-[#bdc3ff]" href="/privacy-policy.html" rel="noreferrer" target="_blank">privacy policy</a>. Ermine stores only my session token in a cookie. 1781 + </span> 1782 + </label> 1783 + 1722 1784 {loginError ? ( 1723 1785 <div className="flex items-start gap-2 rounded-md border border-red-800 bg-red-900/30 p-2 text-sm text-red-200"> 1724 1786 <AlertCircle className="mt-0.5" size={15} /> ··· 1761 1823 1762 1824 {activeModal === 'set-status' ? ( 1763 1825 <Modal onClose={() => setActiveModal(null)} title="Set your status"> 1764 - <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1765 - Presence 1766 - <select className={`${inputBase} mt-1`} onChange={(event) => setStatusDraft((prev) => ({ ...prev, presence: event.target.value }))} value={statusDraft.presence}> 1767 - <option value="Online">Online</option> 1768 - <option value="Idle">Idle</option> 1769 - <option value="Busy">Busy</option> 1770 - <option value="Invisible">Invisible</option> 1771 - </select> 1772 - </label> 1826 + <div> 1827 + <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Presence</p> 1828 + <div className="grid grid-cols-1 gap-2"> 1829 + {STATUS_OPTIONS.map((option) => { 1830 + const Icon = option.icon; 1831 + const selected = statusDraft.presence === option.value; 1832 + const focusVisual = option.value === 'Focus' 1833 + ? 'border-2 border-[#4f7dff] bg-[#4f7dff]/10 p-[6px] text-[#4f7dff]' 1834 + : ''; 1835 + 1836 + return ( 1837 + <button 1838 + className={`flex items-center gap-3 rounded-lg border px-3 py-2 text-left transition ${selected ? 'border-[#5865f2] bg-[#2c3163]/45' : 'border-[#2f3237] bg-[#1f2024] hover:border-[#3f4451] hover:bg-[#282a31]'}`} 1839 + key={option.value} 1840 + onClick={() => setStatusDraft((prev) => ({ ...prev, presence: option.value }))} 1841 + type="button" 1842 + > 1843 + <span className={`rounded-full ${focusVisual || 'p-1.5'} ${option.iconClass}`}> 1844 + <Icon size={15} /> 1845 + </span> 1846 + <span className="text-sm text-gray-100">{option.label}</span> 1847 + </button> 1848 + ); 1849 + })} 1850 + </div> 1851 + </div> 1773 1852 <label className="block text-xs font-semibold uppercase tracking-wide text-gray-400"> 1774 1853 Custom status 1775 1854 <input className={`${inputBase} mt-1`} maxLength={128} onChange={(event) => setStatusDraft((prev) => ({ ...prev, text: event.target.value }))} placeholder="What's up?" value={statusDraft.text} /> ··· 1777 1856 <button className="w-full rounded-md bg-[#5865f2] py-2 text-sm font-semibold text-white hover:bg-[#4956d8]" disabled={isSavingStatus} onClick={saveStatus} type="button"> 1778 1857 {isSavingStatus ? 'Saving…' : 'Save status'} 1779 1858 </button> 1859 + </Modal> 1860 + ) : null} 1861 + 1862 + 1863 + {activeModal === 'user-settings' ? ( 1864 + <Modal onClose={() => setActiveModal(null)} title="User settings"> 1865 + <div className="space-y-3 rounded-md border border-[#2f3237] bg-[#1f2024] p-3 text-sm text-gray-300"> 1866 + <p><span className="font-semibold text-white">Privacy:</span> Ermine stores your session token in a secure browser cookie so you stay logged in.</p> 1867 + <p>No personal profile data is persisted to Ermine repositories or Ermine servers.</p> 1868 + <p>Ermine only communicates with official stoat.chat endpoints configured in this client.</p> 1869 + </div> 1870 + <label className="flex items-start gap-2 rounded-md bg-[#232428] p-2 text-xs text-gray-300"> 1871 + <input checked={privacyAcknowledged} className="mt-0.5" onChange={(event) => setPrivacyAcknowledged(event.target.checked)} type="checkbox" /> 1872 + <span>I understand Ermine uses cookies for the session token and sends chat traffic only to configured stoat.chat services.</span> 1873 + </label> 1874 + <a className="text-xs text-[#8ea1ff] underline hover:text-[#bdc3ff]" href="/privacy-policy.html" rel="noreferrer" target="_blank">Read privacy policy</a> 1780 1875 </Modal> 1781 1876 ) : null} 1782 1877 ··· 1916 2011 <div className="truncate text-[10px] uppercase tracking-wide text-gray-400">{users[auth.userId]?.status?.text || status}</div> 1917 2012 </div> 1918 2013 </button> 1919 - <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={logout} title="Logout"> 1920 - <LogOut size={14} /> 1921 - </button> 2014 + <div className="flex items-center gap-1"> 2015 + <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={() => setActiveModal('user-settings')} title="User settings" type="button"> 2016 + <Settings size={14} /> 2017 + </button> 2018 + <button className="rounded p-1 text-gray-400 hover:bg-[#35373c] hover:text-white" onClick={logout} title="Logout" type="button"> 2019 + <LogOut size={14} /> 2020 + </button> 2021 + </div> 1922 2022 </div> 1923 2023 </div> 1924 2024 </aside>