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

Animate avatars on hover and add rich attachment previews

Fascinating Pistachio (Feb 14, 2026, 3:47 PM UTC) e8039115 6efa292f

+51 -13
+51 -13
src/App.jsx
··· 151 151 return token; 152 152 }; 153 153 154 + 155 + const isImageLike = (filename = '', contentType = '') => { 156 + if (typeof contentType === 'string' && contentType.startsWith('image/')) return true; 157 + return /\.(png|jpe?g|webp|gif|bmp|avif|svg)$/i.test(filename || ''); 158 + }; 159 + 160 + const getAttachmentData = (attachment, cdnUrl, index = 0) => { 161 + const attachmentId = typeof attachment === 'string' ? attachment : attachment?._id || attachment?.id; 162 + const filename = attachment?.filename || attachment?.name || `attachment-${index + 1}`; 163 + const contentType = attachment?.content_type || attachment?.contentType || attachment?.metadata?.type || ''; 164 + const url = attachmentId ? `${cdnUrl}/attachments/${attachmentId}` : null; 165 + const image = isImageLike(filename, contentType); 166 + 167 + return { attachmentId, filename, url, image }; 168 + }; 169 + 154 170 const renderMessageContent = (content, users, channels, onUserClick, customEmojiById, cdnUrl) => { 155 171 if (!content) return null; 156 172 ··· 212 228 213 229 const initials = (user?.username || '?').slice(0, 2).toUpperCase(); 214 230 const [isHovered, setIsHovered] = useState(false); 215 - const hasAnimatedAvatar = Boolean(user?.avatar?.animated); 216 231 const src = (() => { 217 232 if (!user?.avatar?._id) return null; 218 - const wantsAnimated = hasAnimatedAvatar && (alwaysAnimate || (animateOnHover && isHovered)); 233 + const wantsAnimated = alwaysAnimate || (animateOnHover && isHovered); 219 234 return wantsAnimated ? `${cdnUrl}/avatars/${user.avatar._id}/original` : `${cdnUrl}/avatars/${user.avatar._id}`; 220 235 })(); 221 236 ··· 330 345 {Array.isArray(message.attachments) && message.attachments.length ? ( 331 346 <div className="mt-1.5 flex flex-wrap gap-1.5"> 332 347 {message.attachments.map((attachment, index) => { 333 - const attachmentId = typeof attachment === 'string' ? attachment : attachment?._id || attachment?.id; 334 - const attachmentName = attachment?.filename || attachment?.name || `attachment-${index + 1}`; 335 - if (!attachmentId) return null; 348 + const { attachmentId, filename, url, image } = getAttachmentData(attachment, cdnUrl, index); 349 + if (!attachmentId || !url) return null; 350 + 351 + if (image) { 352 + return ( 353 + <a className="block overflow-hidden rounded border border-[#3a3d42]" href={url} key={attachmentId} rel="noreferrer" target="_blank" title={filename}> 354 + <img alt={filename} className="max-h-52 max-w-[320px] object-contain" src={url} /> 355 + </a> 356 + ); 357 + } 358 + 336 359 return ( 337 360 <a 338 361 className="rounded bg-[#232428] px-2 py-1 text-xs text-[#bdc3ff] hover:bg-[#2f3136]" 339 - href={`${cdnUrl}/attachments/${attachmentId}`} 362 + href={url} 340 363 key={attachmentId} 341 364 rel="noreferrer" 342 365 target="_blank" 343 366 > 344 - 📎 {attachmentName} 367 + 📎 {filename} 345 368 </a> 346 369 ); 347 370 })} ··· 1369 1392 event.target.value = ''; 1370 1393 }; 1371 1394 1395 + const handleComposerDrop = (event) => { 1396 + event.preventDefault(); 1397 + const files = Array.from(event.dataTransfer?.files || []); 1398 + if (!files.length) return; 1399 + setPendingFiles((prev) => [...prev, ...files]); 1400 + }; 1401 + 1402 + const handleComposerDragOver = (event) => { 1403 + event.preventDefault(); 1404 + }; 1405 + 1372 1406 const removePendingFile = (index) => { 1373 1407 setPendingFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); 1374 1408 }; ··· 1723 1757 ) : null} 1724 1758 {pendingFiles.length ? ( 1725 1759 <div className="mb-2 flex flex-wrap gap-1.5"> 1726 - {pendingFiles.map((file, index) => ( 1727 - <button className="rounded bg-[#2b2d31] px-2 py-1 text-xs text-gray-200 hover:bg-[#35373c]" key={`${file.name}-${index}`} onClick={() => removePendingFile(index)} type="button"> 1728 - 📎 {file.name} <span className="text-gray-400">(remove)</span> 1729 - </button> 1730 - ))} 1760 + {pendingFiles.map((file, index) => { 1761 + const previewImage = isImageLike(file.name, file.type); 1762 + return ( 1763 + <button className="flex items-center gap-2 rounded bg-[#2b2d31] px-2 py-1 text-xs text-gray-200 hover:bg-[#35373c]" key={`${file.name}-${index}`} onClick={() => removePendingFile(index)} type="button"> 1764 + <span>{previewImage ? '🖼️' : '📎'}</span> 1765 + <span>{file.name} <span className="text-gray-400">(remove)</span></span> 1766 + </button> 1767 + ); 1768 + })} 1731 1769 </div> 1732 1770 ) : null} 1733 1771 1734 - <div className="flex items-center gap-2 rounded-lg bg-[#383a40] p-2"> 1772 + <div className="flex items-center gap-2 rounded-lg bg-[#383a40] p-2" onDragOver={handleComposerDragOver} onDrop={handleComposerDrop}> 1735 1773 <button 1736 1774 className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" 1737 1775 disabled={selectedChannelId === 'friends' || isUploadingFiles}