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

Update App.jsx

authored by

Fascinating Pistachio and committed by
GitHub
(Feb 15, 2026, 8:54 PM UTC) 8b8f8d44 85030f4b

+87 -36
+87 -36
src/App.jsx
··· 22 22 Edit2, 23 23 Save, 24 24 MoreVertical, 25 - Search 25 + Search, 26 + Image as ImageIcon 26 27 } from 'lucide-react'; 27 28 28 29 // --- ULID Decoder for Timestamps --- ··· 346 347 return token; 347 348 }; 348 349 349 - const isImageLike = (filename = '', contentType = '') => (typeof contentType === 'string' && contentType.startsWith('image/')) || /\.(png|jpe?g|webp|gif|bmp|avif|svg)$/i.test(filename || ''); 350 + const isImageLike = (filename = '', contentType = '') => { 351 + if (typeof contentType === 'string' && contentType.startsWith('image/')) return true; 352 + const ext = filename?.split('.').pop()?.toLowerCase(); 353 + return ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'avif', 'svg'].includes(ext); 354 + }; 355 + 350 356 const getAttachmentData = (attachment, cdnUrl, index = 0) => { 351 357 const attachmentId = typeof attachment === 'string' ? attachment : attachment?._id || attachment?.id; 352 358 const filename = attachment?.filename || attachment?.name || `attachment-${index + 1}`; ··· 359 365 const extractUrls = (value = '') => (value.match(/https?:\/\/[^\s]+/gi) || []); 360 366 const isEmbeddableGifLink = (url) => url && (/^https?:\/\/media\.tenor\.com\//i.test(url) || /\.(gif)(\?.*)?$/i.test(url)); 361 367 368 + const isEmbeddableImageLink = (url) => { 369 + if (!url) return false; 370 + const cleanUrl = url.split('?')[0].toLowerCase(); 371 + return /\.(png|jpe?g|webp|gif|bmp|avif|svg)$/i.test(cleanUrl); 372 + }; 373 + 374 + 362 375 const renderMessageContent = (content, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink) => { 363 376 if (!content) return null; 364 377 const parts = content.split(/(<@!?[A-Za-z0-9]+>|<#[A-Za-z0-9]+>|:[A-Z0-9]{26}:)/g); ··· 371 384 } 372 385 const channelMention = part.match(/^<#([A-Za-z0-9]+)>$/); 373 386 if (channelMention) return <span className="mx-0.5 inline rounded bg-[#3f4249] px-1 text-gray-100" key={`${part}-${index}`}>#{channels[channelMention[1]]?.name || 'unknown-channel'}</span>; 387 + 388 + // Check for Custom Emoji Token :ID: 374 389 if (isCustomEmojiToken(part)) { 375 390 const customEmoji = resolveCustomEmojiMeta(part, customEmojiById); 376 391 return <span className="mx-0.5 inline-flex items-center" key={`${part}-${index}`} title={`${customEmoji?.name || part}`}>{renderEmojiVisual(part, customEmoji, cdnUrl)}</span>; 377 392 } 393 + 394 + // Check for Shortcodes 378 395 const shortcodeMatch = part.match(/^:([a-z0-9_+-]+):$/i); 379 396 if (shortcodeMatch) { 380 397 const mapped = EMOJI_SHORTCODES[shortcodeMatch[1].toLowerCase()]; 381 398 if (mapped) return renderTwemoji(mapped); 382 399 } 400 + 383 401 const textElement = renderMarkdownInline(part, `md-${index}`, onRequestOpenLink); 384 402 return typeof textElement === 'string' ? <React.Fragment key={`${part}-${index}`}>{renderTwemoji(textElement)}</React.Fragment> : <React.Fragment key={`${part}-${index}`}>{textElement}</React.Fragment>; 385 403 }); ··· 465 483 466 484 const [showReactionPicker, setShowReactionPicker] = useState(false); 467 485 const [isMessageHovered, setIsMessageHovered] = useState(false); 468 - const embeddedGifUrls = useMemo(() => extractUrls(message.content || '').filter(isEmbeddableGifLink), [message.content]); 469 - const [resolvedGifUrls, setResolvedGifUrls] = useState({}); 486 + 487 + const embeddedLinks = useMemo(() => extractUrls(message.content || ''), [message.content]); 488 + const [resolvedImages, setResolvedImages] = useState([]); 489 + 470 490 const [isEditing, setIsEditing] = useState(false); 471 491 const [editContent, setEditContent] = useState(''); 472 492 ··· 483 503 484 504 useEffect(() => { 485 505 let cancelled = false; 486 - const resolveTenorUrls = async () => { 487 - const tenorLinks = embeddedGifUrls.filter((url) => /^https?:\/\/tenor\.com\//i.test(url)); 488 - if (!tenorLinks.length) return; 489 - const results = await Promise.all(tenorLinks.map(async (url) => { 490 - try { 491 - const response = await fetch(`https://tenor.com/oembed?url=${encodeURIComponent(url)}`); 492 - if (!response.ok) return [url, url]; 493 - const data = await response.json(); 494 - return [url, data?.url || data?.thumbnail_url || url]; 495 - } catch { return [url, url]; } 506 + const resolve = async () => { 507 + const results = await Promise.all(embeddedLinks.map(async (url) => { 508 + if (/^https?:\/\/media\.tenor\.com\//i.test(url)) { 509 + try { 510 + const response = await fetch(`https://tenor.com/oembed?url=${encodeURIComponent(url)}`); 511 + if (!response.ok) return { url, type: 'image', src: url }; 512 + const data = await response.json(); 513 + return { url, type: 'image', src: data?.url || data?.thumbnail_url || url }; 514 + } catch { return { url, type: 'image', src: url }; } 515 + } 516 + if (isEmbeddableImageLink(url)) { 517 + return { url, type: 'image', src: url }; 518 + } 519 + return null; 496 520 })); 497 - if (!cancelled) setResolvedGifUrls((prev) => ({ ...prev, ...Object.fromEntries(results) })); 521 + const validResults = results.filter(Boolean); 522 + if (!cancelled) setResolvedImages(validResults); 498 523 }; 499 - void resolveTenorUrls(); 524 + if(embeddedLinks.length > 0) resolve(); else setResolvedImages([]); 500 525 return () => { cancelled = true; }; 501 - }, [embeddedGifUrls]); 526 + }, [embeddedLinks]); 502 527 503 - const contentWithoutEmbeddedGifLinks = useMemo(() => { 528 + const contentWithoutLinks = useMemo(() => { 504 529 let next = message.content || ''; 505 - embeddedGifUrls.forEach((url) => { next = next.split(url).join(''); }); 506 530 return next.trim(); 507 - }, [embeddedGifUrls, message.content]); 531 + }, [message.content]); 508 532 509 533 const startEditing = () => { setEditContent(message.content || ''); setIsEditing(true); setShowReactionPicker(false); }; 510 534 const saveEdit = () => { if (editContent.trim() !== message.content) onEditMessage(message._id, editContent); setIsEditing(false); }; ··· 552 576 </div> 553 577 ) : ( 554 578 <> 555 - {contentWithoutEmbeddedGifLinks ? <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(contentWithoutEmbeddedGifLinks, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink)}</p> : null} 579 + {contentWithoutLinks ? <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(contentWithoutLinks, users, channels, onUserClick, customEmojiById, cdnUrl, onRequestOpenLink)}</p> : null} 556 580 </> 557 581 )} 558 582 559 - {embeddedGifUrls.length ? ( 583 + {resolvedImages.length > 0 && ( 560 584 <div className="mt-1.5 flex flex-wrap gap-2"> 561 - {embeddedGifUrls.slice(0, 3).map((url, idx) => { 562 - const renderUrl = resolvedGifUrls[url] || url; 563 - return <button className="block overflow-hidden rounded border border-[#3a3d42]" key={`${url}-${idx}`} onClick={() => onRequestOpenLink(url)} title={url} type="button"><img alt="Embedded GIF" className="max-h-52 max-w-[320px] object-contain" src={renderUrl} /></button>; 564 - })} 585 + {resolvedImages.map((img, idx) => ( 586 + <button className="block overflow-hidden rounded-lg border border-[#2f3237]" key={`${img.url}-${idx}`} onClick={() => onRequestOpenLink(img.url)} title={img.url} type="button"> 587 + <img alt="Embed" className="max-h-64 max-w-full object-contain" src={img.src} loading="lazy" /> 588 + </button> 589 + ))} 565 590 </div> 566 - ) : null} 567 - {Array.isArray(message.attachments) && message.attachments.length ? ( 591 + )} 592 + 593 + {Array.isArray(message.attachments) && message.attachments.length > 0 && ( 568 594 <div className="mt-1.5 flex flex-wrap gap-1.5"> 569 595 {message.attachments.map((attachment, index) => { 570 596 const { attachmentId, filename, url, image } = getAttachmentData(attachment, cdnUrl, index); 571 597 if (!attachmentId || !url) return null; 572 - if (image) return <a className="block overflow-hidden rounded border border-[#3a3d42]" href={url} key={attachmentId} rel="noreferrer" target="_blank" title={filename}><img alt={filename} className="max-h-52 max-w-[320px] object-contain" src={url} /></a>; 598 + if (image) return <a className="block overflow-hidden rounded border border-[#3a3d42]" href={url} key={attachmentId} rel="noreferrer" target="_blank" title={filename}><img alt={filename} className="max-h-64 max-w-full object-contain" src={url} /></a>; 573 599 return <a className="rounded bg-[#232428] px-2 py-1 text-xs text-[#bdc3ff] hover:bg-[#2f3136]" href={url} key={attachmentId} rel="noreferrer" target="_blank">📎 {filename}</a>; 574 600 })} 575 601 </div> 576 - ) : null} 602 + )} 603 + 577 604 <div className="mt-1 flex flex-wrap items-center gap-1.5"> 578 605 {messageReactions.map(({ emoji, userIds }) => { 579 606 const reacted = userIds.includes(me); ··· 710 737 const fileInputRef = useRef(null); 711 738 const autoFollowRef = useRef(true); 712 739 const isProgrammaticScrollRef = useRef(false); 740 + 741 + const pickerRef = useRef(null); 742 + 743 + useEffect(() => { 744 + if (!showEmojiPicker) return; 745 + const handleClickOutside = (event) => { 746 + if (pickerRef.current && !pickerRef.current.contains(event.target)) setShowEmojiPicker(false); 747 + }; 748 + document.addEventListener('mousedown', handleClickOutside); 749 + return () => document.removeEventListener('mousedown', handleClickOutside); 750 + }, [showEmojiPicker]); 713 751 714 752 // --- Helper Functions in Scope --- 715 753 const isSameOriginApi = useMemo(() => { try { return new URL(config.apiUrl).origin === window.location.origin; } catch { return false; } }, [config.apiUrl]); ··· 820 858 } catch {} 821 859 }, [auth.token, config.apiUrl, upsertUsersFromMessages]); 822 860 823 - const fetchMessages = useCallback(async (channelId) => { 861 + const fetchMessages = useCallback(async (channelId, beforeId = null) => { 824 862 if (!channelId || channelId === 'friends') return; 825 863 try { 826 - const res = await fetch(`${config.apiUrl}/channels/${channelId}/messages?limit=100`, { headers: { 'x-session-token': auth.token } }); 864 + const url = `${config.apiUrl}/channels/${channelId}/messages?limit=100${beforeId ? `&before=${beforeId}` : ''}`; 865 + const res = await fetch(url, { headers: { 'x-session-token': auth.token } }); 827 866 if (!res.ok) return; 828 867 const data = await res.json(); 829 868 const payloadMessages = Array.isArray(data) ? data : data.messages || []; 830 869 const payloadUsers = Array.isArray(data) ? [] : data.users || []; 831 870 if (payloadUsers.length) upsertUsers(payloadUsers); 832 871 upsertUsersFromMessages(payloadMessages); 833 - const orderedMessages = payloadMessages.reverse().slice(-200); 834 - setMessages((prev) => ({ ...prev, [channelId]: uniqueMessages(orderedMessages) })); 872 + 873 + const orderedMessages = payloadMessages.reverse(); 874 + 875 + setMessages((prev) => { 876 + const existing = prev[channelId] || []; 877 + if (beforeId) { 878 + // Merging older messages 879 + return { ...prev, [channelId]: uniqueMessages([...orderedMessages, ...existing]) }; 880 + } else { 881 + // Initial load or new load 882 + return { ...prev, [channelId]: uniqueMessages(orderedMessages) }; 883 + } 884 + }); 885 + 835 886 void fetchMissingUsers(orderedMessages); 836 - preloadedChannelRef.current[channelId] = true; 887 + if (!beforeId) preloadedChannelRef.current[channelId] = true; 837 888 } catch {} 838 889 }, [auth.token, config.apiUrl, upsertUsers, upsertUsersFromMessages, fetchMissingUsers]); 839 890 ··· 1130 1181 <div className="flex flex-col items-center"> 1131 1182 <div className="w-full h-24 rounded-t-lg bg-gray-700 overflow-hidden relative"> 1132 1183 {getBannerUrl(users[auth.userId], config.cdnUrl) ? ( 1133 - <img src={getBannerUrl(users[auth.userId], config.cdnUrl)} alt="Banner" className="w-full h-full object-cover" /> 1184 + <img src={getBannerUrl(users[auth.userId], config.cdnUrl)} alt="Banner" className="w-full h-full object-cover rounded-t-lg" /> 1134 1185 ) : ( 1135 1186 <div className="w-full h-full bg-gradient-to-r from-[#3b3f6b] to-[#5865f2]" /> 1136 1187 )}