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

Fix reply behavior, profile bio data, and markdown rendering

Fascinating Pistachio (Feb 14, 2026, 3:26 PM UTC) 62a8288c cc50b1c8

+152 -18
+152 -18
src/App.jsx
··· 48 48 return `${cdnUrl}/icons/${server.icon._id}`; 49 49 }; 50 50 51 + 52 + const getBannerUrl = (user, cdnUrl) => { 53 + const banner = user?.profile?.background || user?.banner; 54 + if (!banner?._id) return null; 55 + return `${cdnUrl}/backgrounds/${banner._id}?max_side=1024`; 56 + }; 57 + 58 + const getJoinedAt = (entry) => entry?.joined_at || entry?.joinedAt || entry?.created_at || entry?.createdAt || null; 59 + 60 + const toDateLabel = (value) => { 61 + if (!value) return 'Unknown'; 62 + const date = new Date(value); 63 + if (Number.isNaN(date.getTime())) return 'Unknown'; 64 + return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); 65 + }; 66 + 67 + const renderMarkdownInline = (text, keyPrefix = 'md') => { 68 + if (!text) return null; 69 + const tokens = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|~~[^~]+~~|\[[^\]]+\]\([^\)]+\))/g); 70 + 71 + return tokens.map((token, index) => { 72 + if (!token) return null; 73 + 74 + if (/^`[^`]+`$/.test(token)) { 75 + return <code className="rounded bg-[#232428] px-1 py-0.5 text-xs text-[#f2f3f5]" key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</code>; 76 + } 77 + 78 + if (/^\*\*[^*]+\*\*$/.test(token)) { 79 + return <strong key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</strong>; 80 + } 81 + 82 + if (/^\*[^*]+\*$/.test(token)) { 83 + return <em key={`${keyPrefix}-${index}`}>{token.slice(1, -1)}</em>; 84 + } 85 + 86 + if (/^__[^_]+__$/.test(token)) { 87 + return <span className="underline" key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</span>; 88 + } 89 + 90 + if (/^~~[^~]+~~$/.test(token)) { 91 + return <span className="line-through" key={`${keyPrefix}-${index}`}>{token.slice(2, -2)}</span>; 92 + } 93 + 94 + const markdownLink = token.match(/^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/i); 95 + if (markdownLink) { 96 + return <a className="text-[#8ea1ff] underline hover:text-[#bdc3ff]" href={markdownLink[2]} key={`${keyPrefix}-${index}`} rel="noreferrer" target="_blank">{markdownLink[1]}</a>; 97 + } 98 + 99 + return <React.Fragment key={`${keyPrefix}-${index}`}>{token}</React.Fragment>; 100 + }); 101 + }; 102 + 51 103 const EMOJI_SHORTCODES = { 52 104 smile: '😄', 53 105 grin: '😁', ··· 79 131 .filter((entry) => entry.userIds.length > 0); 80 132 }; 81 133 134 + 135 + const getReplyIdFromMessage = (message) => { 136 + const firstReply = Array.isArray(message?.replies) ? message.replies[0] : null; 137 + if (!firstReply) return null; 138 + return typeof firstReply === 'string' ? firstReply : firstReply.id || firstReply._id || null; 139 + }; 140 + 82 141 const renderMessageContent = (content, users, channels, onUserClick) => { 83 142 if (!content) return null; 84 143 ··· 118 177 return EMOJI_SHORTCODES[emoji[1].toLowerCase()] || part; 119 178 } 120 179 121 - return <React.Fragment key={`${part}-${index}`}>{part}</React.Fragment>; 180 + return <React.Fragment key={`${part}-${index}`}>{renderMarkdownInline(part, `md-${index}`)}</React.Fragment>; 122 181 }); 123 182 }; 124 183 ··· 318 377 const loggedMissingUserFetchErrorRef = useRef(false); 319 378 const wsReconnectTimeoutRef = useRef(null); 320 379 const messageRefs = useRef({}); 380 + const replyMessageCacheRef = useRef({}); 321 381 322 382 const serverList = useMemo(() => Object.values(servers), [servers]); 323 383 ··· 418 478 return []; 419 479 }, [peekUser]); 420 480 481 + 482 + const peekAboutMe = useMemo( 483 + () => peekUser?.profile?.content || peekUser?.profile?.bio || peekUser?.bio || null, 484 + [peekUser], 485 + ); 486 + 487 + const peekPlatformJoined = useMemo(() => getJoinedAt(peekUser), [peekUser]); 488 + const peekServerJoined = useMemo(() => getJoinedAt(peekMember), [peekMember]); 489 + const peekBannerUrl = useMemo(() => getBannerUrl(peekUser, config.cdnUrl), [peekUser, config.cdnUrl]); 490 + 421 491 const isSameOriginApi = useMemo(() => { 422 492 try { 423 493 return new URL(config.apiUrl).origin === window.location.origin; ··· 583 653 const list = prev[packet.channel] || []; 584 654 if (list.find((m) => m._id === packet._id)) return prev; 585 655 const nextMessages = [...list, packet].slice(-200); 586 - return { ...prev, [packet.channel]: enrichReplies(nextMessages) }; 656 + return { ...prev, [packet.channel]: enrichReplies(nextMessages, packet.channel) }; 587 657 }); 588 658 break; 589 659 case 'MessageUpdate': 590 660 setMessages((prev) => ({ 591 661 ...prev, 592 - [packet.channel]: enrichReplies((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m))), 662 + [packet.channel]: enrichReplies((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m)), packet.channel), 593 663 })); 594 664 break; 595 665 case 'MessageDelete': ··· 742 812 }, [currentMessages]); 743 813 744 814 useEffect(() => { 815 + if (!selectedChannelId || selectedChannelId === 'friends') return; 816 + 817 + const missingReplyIds = currentMessages 818 + .map((message) => getReplyIdFromMessage(message)) 819 + .filter((replyId) => replyId && !replyMessageCacheRef.current[replyId] && !currentMessageMap[replyId]); 820 + 821 + [...new Set(missingReplyIds)].slice(0, 10).forEach((replyId) => { 822 + void fetchReplyMessage(selectedChannelId, replyId); 823 + }); 824 + }, [currentMessages, currentMessageMap, selectedChannelId]); 825 + 826 + useEffect(() => { 745 827 if (!voiceNotice) return undefined; 746 828 const timeout = setTimeout(() => setVoiceNotice(''), 3500); 747 829 return () => clearTimeout(timeout); ··· 789 871 } 790 872 }; 791 873 792 - const enrichReplies = (nextMessages) => { 874 + const resolveReplyPreview = (messageById, channelId, replyId) => { 875 + const inBatch = messageById[replyId]; 876 + if (inBatch) return inBatch; 877 + 878 + const inChannelState = (messages[channelId] || []).find((entry) => entry._id === replyId); 879 + if (inChannelState) return inChannelState; 880 + 881 + return replyMessageCacheRef.current[replyId] || null; 882 + }; 883 + 884 + const enrichReplies = (nextMessages = [], channelId = selectedChannelId) => { 793 885 const messageById = Object.fromEntries(nextMessages.map((entry) => [entry._id, entry])); 794 886 795 887 return nextMessages.map((message) => { 796 - const replyId = Array.isArray(message.replies) ? message.replies[0] : null; 797 - const replyMessage = replyId ? messageById[replyId] : null; 798 - if (!replyMessage) return message; 888 + const replyId = getReplyIdFromMessage(message); 889 + if (!replyId) return { ...message, replyMessage: null }; 890 + 891 + const replyMessage = resolveReplyPreview(messageById, channelId, replyId); 892 + if (!replyMessage) { 893 + return { 894 + ...message, 895 + replyMessage: { 896 + _id: replyId, 897 + content: 'Loading reply…', 898 + authorId: null, 899 + authorUser: null, 900 + }, 901 + }; 902 + } 799 903 const replyAuthorId = typeof replyMessage.author === 'string' ? replyMessage.author : replyMessage.author?._id; 800 904 801 905 return { ··· 810 914 }); 811 915 }; 812 916 917 + const fetchReplyMessage = async (channelId, replyId) => { 918 + if (!channelId || !replyId || replyMessageCacheRef.current[replyId]) return; 919 + 920 + try { 921 + const res = await fetch(`${config.apiUrl}/channels/${channelId}/messages/${replyId}`, { 922 + headers: { 'x-session-token': auth.token }, 923 + }); 924 + if (!res.ok) return; 925 + const data = await res.json(); 926 + if (!data?._id) return; 927 + 928 + replyMessageCacheRef.current[replyId] = data; 929 + upsertUsersFromMessages([data]); 930 + setMessages((prev) => ({ 931 + ...prev, 932 + [channelId]: enrichReplies(prev[channelId] || [], channelId), 933 + })); 934 + } catch { 935 + // no-op 936 + } 937 + }; 938 + 813 939 const fetchMessages = async (channelId) => { 814 940 if (!channelId || channelId === 'friends') return; 815 941 ··· 829 955 830 956 upsertUsersFromMessages(payloadMessages); 831 957 const orderedMessages = payloadMessages.reverse().slice(-200); 832 - setMessages((prev) => ({ ...prev, [channelId]: enrichReplies(orderedMessages) })); 958 + setMessages((prev) => ({ ...prev, [channelId]: enrichReplies(orderedMessages, channelId) })); 833 959 void fetchMissingUsers(orderedMessages); 834 960 preloadedChannelRef.current[channelId] = true; 835 961 } catch { ··· 1033 1159 content, 1034 1160 createdAt: new Date().toISOString(), 1035 1161 reactions: {}, 1036 - replies: replyingTo ? [replyingTo._id] : undefined, 1162 + replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, 1037 1163 }; 1038 1164 1039 1165 setMessages((prev) => { 1040 1166 const list = prev[selectedChannelId] || []; 1041 - return { ...prev, [selectedChannelId]: enrichReplies([...list, optimisticMessage].slice(-200)) }; 1167 + return { ...prev, [selectedChannelId]: enrichReplies([...list, optimisticMessage].slice(-200), selectedChannelId) }; 1042 1168 }); 1043 1169 1044 1170 try { ··· 1051 1177 body: JSON.stringify({ 1052 1178 content, 1053 1179 nonce, 1054 - replies: replyingTo ? [replyingTo._id] : undefined, 1180 + replies: replyingTo ? [{ id: replyingTo._id, mention: false }] : undefined, 1055 1181 }), 1056 1182 }); 1057 1183 ··· 1062 1188 upsertUsersFromMessages([postedMessage]); 1063 1189 setMessages((prev) => { 1064 1190 const list = (prev[selectedChannelId] || []).map((entry) => (entry._id === pendingId ? postedMessage : entry)); 1065 - return { ...prev, [selectedChannelId]: enrichReplies(list) }; 1191 + return { ...prev, [selectedChannelId]: enrichReplies(list, selectedChannelId) }; 1066 1192 }); 1067 1193 } 1068 1194 ··· 1251 1377 1252 1378 {peekUser ? ( 1253 1379 <Modal onClose={() => setPeekUser(null)} title="Member profile"> 1254 - <div className="flex items-center gap-3 rounded-lg bg-[#1e1f22] p-3"> 1255 - <Avatar alwaysAnimate cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 1256 - <div> 1257 - <div className="text-base font-semibold text-white">{peekUser?.username || 'Unknown user'}</div> 1258 - <div className="text-xs text-gray-400">#{peekUser?.discriminator || '0000'}</div> 1380 + <div className="overflow-hidden rounded-lg bg-[#1e1f22]"> 1381 + {peekBannerUrl ? <img alt="Profile banner" className="h-24 w-full object-cover" src={peekBannerUrl} /> : <div className="h-20 w-full bg-gradient-to-r from-[#3b3f6b] to-[#5865f2]" />} 1382 + <div className="flex items-center gap-3 p-3"> 1383 + <Avatar alwaysAnimate cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 1384 + <div> 1385 + <div className="text-base font-semibold text-white">{peekUser?.username || 'Unknown user'}</div> 1386 + <div className="text-xs text-gray-400">#{peekUser?.discriminator || '0000'}</div> 1387 + </div> 1259 1388 </div> 1260 1389 </div> 1261 1390 1391 + <div className="grid grid-cols-1 gap-2 rounded-md bg-[#1e1f22] p-3 text-xs text-gray-300"> 1392 + <p><span className="font-semibold text-gray-100">Joined platform:</span> {toDateLabel(peekPlatformJoined)}</p> 1393 + <p><span className="font-semibold text-gray-100">Joined server:</span> {selectedServerId === '@me' ? 'N/A' : toDateLabel(peekServerJoined)}</p> 1394 + </div> 1395 + 1262 1396 <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1263 1397 <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">About me</p> 1264 - <p className="text-sm text-gray-200">{peekUser?.profile?.content || peekUser?.status?.text || 'This user has not set an About Me yet.'}</p> 1398 + <p className="text-sm text-gray-200">{peekAboutMe || 'This user has not set an About Me yet.'}</p> 1265 1399 </div> 1266 1400 1267 1401 <div className="space-y-1 rounded-md bg-[#1e1f22] p-3">