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

Improve scroll behavior and add trusted-link prompts

Fascinating Pistachio (Feb 14, 2026, 4:01 PM UTC) 1798984d b8fab98e

+78 -5
+78 -5
src/App.jsx
··· 65 65 return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); 66 66 }; 67 67 68 + 69 + const shouldTrustLink = (url) => window.confirm(`Do you trust this link? 70 + 71 + ${url}`); 72 + 73 + const LinkAnchor = ({ href, children, className = 'text-[#8ea1ff] underline hover:text-[#bdc3ff]' }) => ( 74 + <a 75 + className={className} 76 + href={href} 77 + onClick={(event) => { 78 + if (!shouldTrustLink(href)) event.preventDefault(); 79 + }} 80 + rel="noreferrer" 81 + target="_blank" 82 + > 83 + {children} 84 + </a> 85 + ); 86 + 87 + const renderLinksInText = (value, keyPrefix = 'link') => { 88 + if (!value) return null; 89 + const parts = value.split(/(https?:\/\/[^\s]+)/gi); 90 + return parts.map((part, index) => { 91 + if (!part) return null; 92 + if (/^https?:\/\//i.test(part)) { 93 + return <LinkAnchor href={part} key={`${keyPrefix}-${index}`}>{part}</LinkAnchor>; 94 + } 95 + return <React.Fragment key={`${keyPrefix}-${index}`}>{part}</React.Fragment>; 96 + }); 97 + }; 98 + 68 99 const renderMarkdownInline = (text, keyPrefix = 'md') => { 69 100 if (!text) return null; 70 101 const tokens = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^\)]+\))/g); ··· 94 125 95 126 const markdownLink = token.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/i); 96 127 if (markdownLink) { 97 - return <a className="text-[#8ea1ff] underline hover:text-[#bdc3ff]" href={markdownLink[2]} key={`${keyPrefix}-${index}`} rel="noreferrer" target="_blank">{markdownLink[1]}</a>; 128 + return <LinkAnchor href={markdownLink[2]} key={`${keyPrefix}-${index}`}>{markdownLink[1]}</LinkAnchor>; 98 129 } 99 130 100 - return <React.Fragment key={`${keyPrefix}-${index}`}>{token}</React.Fragment>; 131 + return <React.Fragment key={`${keyPrefix}-${index}`}>{renderLinksInText(token, `${keyPrefix}-plain-${index}`)}</React.Fragment>; 101 132 }); 102 133 }; 103 134 ··· 458 489 const [pendingFiles, setPendingFiles] = useState([]); 459 490 const [isUploadingFiles, setIsUploadingFiles] = useState(false); 460 491 const [voiceNotice, setVoiceNotice] = useState(''); 492 + const [showGoLatest, setShowGoLatest] = useState(false); 461 493 462 494 const [loginMode, setLoginMode] = useState('credentials'); 463 495 const [email, setEmail] = useState(''); ··· 470 502 471 503 const wsRef = useRef(null); 472 504 const messagesBottomRef = useRef(null); 505 + const messagesContainerRef = useRef(null); 473 506 const subscriptionRef = useRef({}); 474 507 const preloadedChannelRef = useRef({}); 475 508 const preloadedMembersRef = useRef({}); ··· 943 976 }; 944 977 }, [selectedServerId, status]); 945 978 979 + const isNearBottom = () => { 980 + const container = messagesContainerRef.current; 981 + if (!container) return true; 982 + const threshold = 72; 983 + return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; 984 + }; 985 + 946 986 useEffect(() => { 947 - messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 987 + const container = messagesContainerRef.current; 988 + if (!container) return; 989 + 990 + const handleScroll = () => { 991 + const nearBottom = isNearBottom(); 992 + if (nearBottom) setShowGoLatest(false); 993 + }; 994 + 995 + container.addEventListener('scroll', handleScroll); 996 + return () => container.removeEventListener('scroll', handleScroll); 997 + }, [selectedChannelId]); 998 + 999 + useEffect(() => { 1000 + if (isNearBottom()) { 1001 + messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1002 + setShowGoLatest(false); 1003 + return; 1004 + } 1005 + 1006 + setShowGoLatest(true); 948 1007 }, [currentMessages]); 949 1008 950 1009 useEffect(() => { ··· 1202 1261 } else { 1203 1262 delete messageRefs.current[messageId]; 1204 1263 } 1264 + }; 1265 + 1266 + 1267 + const goToLatest = () => { 1268 + messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 1269 + setShowGoLatest(false); 1205 1270 }; 1206 1271 1207 1272 const handleLogin = async (event) => { ··· 1701 1766 </div> 1702 1767 </aside> 1703 1768 1704 - <main className="flex min-w-0 flex-1 flex-col bg-[#313338]"> 1769 + <main className="relative flex min-w-0 flex-1 flex-col bg-[#313338]"> 1705 1770 <header className="flex items-center justify-between border-b border-[#202225] px-4 py-3"> 1706 1771 <div className="flex items-center gap-2 text-sm font-semibold text-white"> 1707 1772 {channels[selectedChannelId]?.channel_type === 'DirectMessage' ? <MessageSquare size={17} className="text-gray-400" /> : <Hash size={17} className="text-gray-400" />} ··· 1716 1781 </div> 1717 1782 ) : null} 1718 1783 1719 - <section className="flex-1 overflow-y-auto py-2"> 1784 + <section className="flex-1 overflow-y-auto py-2" ref={messagesContainerRef}> 1720 1785 {selectedChannelId === 'friends' ? ( 1721 1786 <div className="space-y-2 p-4"> 1722 1787 <h2 className="text-xs font-bold uppercase tracking-wide text-gray-400">Friends ({friends.length})</h2> ··· 1753 1818 )} 1754 1819 <div ref={messagesBottomRef} /> 1755 1820 </section> 1821 + 1822 + {showGoLatest ? ( 1823 + <div className="pointer-events-none absolute bottom-24 left-1/2 z-20 -translate-x-1/2"> 1824 + <button className="pointer-events-auto rounded-full bg-[#5865f2] px-3 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-[#4956d8]" onClick={goToLatest} type="button"> 1825 + Go to latest 1826 + </button> 1827 + </div> 1828 + ) : null} 1756 1829 1757 1830 <footer className="border-t border-[#202225] p-4"> 1758 1831 {activeReply ? (