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

Add reactions, emoji picker, and message replies

Fascinating Pistachio (Feb 14, 2026, 2:43 PM UTC) ae54c796 0d565bd1

+167 -9
+167 -9
src/App.jsx
··· 6 6 LogOut, 7 7 MessageSquare, 8 8 Plus, 9 + Reply, 9 10 Send, 10 11 Settings, 11 12 ShieldCheck, 13 + Smile, 12 14 User, 13 15 Users, 14 16 X, ··· 60 62 thinking: '🤔', 61 63 tada: '🎉', 62 64 eyes: '👀', 65 + }; 66 + 67 + const QUICK_EMOJIS = ['👍', '❤️', '😂', '🔥', '😭', '🎉', '😮', '👀', '🤔']; 68 + 69 + const toReactionEntries = (reactions) => { 70 + if (!reactions || typeof reactions !== 'object') return []; 71 + return Object.entries(reactions) 72 + .map(([emoji, userIds]) => ({ emoji, userIds: Array.isArray(userIds) ? userIds : [] })) 73 + .filter((entry) => entry.userIds.length > 0); 63 74 }; 64 75 65 76 const renderMessageContent = (content, users, channels, onUserClick) => { ··· 146 157 </div> 147 158 ); 148 159 149 - const Message = ({ message, users, channels, me, onUserClick, cdnUrl }) => { 160 + const Message = ({ message, users, channels, me, onUserClick, cdnUrl, onToggleReaction, onReply, replyTarget }) => { 150 161 const authorId = typeof message.author === 'string' ? message.author : message.author?._id; 151 162 const author = users[authorId] || (typeof message.author === 'object' ? message.author : null) || { username: 'Unknown user' }; 152 163 const mine = me === authorId; 164 + const messageReactions = toReactionEntries(message.reactions); 165 + const replyPreview = message.replyMessage; 153 166 154 167 return ( 155 168 <article className="group flex gap-3 rounded px-4 py-2 hover:bg-[#2e3035]"> 156 - <button className="mt-0.5" onClick={() => onUserClick(author, authorId)}> 169 + <button className="mt-0.5" onClick={() => onUserClick(author, authorId)} type="button"> 157 170 <Avatar cdnUrl={cdnUrl} user={author} /> 158 171 </button> 159 172 <div className="min-w-0 flex-1"> 173 + {replyPreview ? ( 174 + <button 175 + className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200" 176 + onClick={() => onUserClick(replyPreview.authorUser || { username: 'Unknown user' }, replyPreview.authorId)} 177 + type="button" 178 + > 179 + <Reply size={12} /> 180 + <span className="truncate">Replying to {replyPreview.authorUser?.username || 'Unknown user'}: {replyPreview.content || 'Attachment / embed'}</span> 181 + </button> 182 + ) : null} 160 183 <div className="flex items-baseline gap-2"> 161 - <button className="text-sm font-semibold text-white hover:underline" onClick={() => onUserClick(author, authorId)}> 184 + <button className="text-sm font-semibold text-white hover:underline" onClick={() => onUserClick(author, authorId)} type="button"> 162 185 {author.username} 163 186 </button> 164 187 {mine && <span className="rounded bg-[#5865f2]/20 px-1.5 py-0.5 text-[10px] font-semibold text-[#bdc3ff]">YOU</span>} ··· 167 190 {message.content ? ( 168 191 <p className="whitespace-pre-wrap break-words text-sm text-gray-200">{renderMessageContent(message.content, users, channels, onUserClick)}</p> 169 192 ) : null} 193 + <div className="mt-1 flex flex-wrap items-center gap-1.5"> 194 + {messageReactions.map(({ emoji, userIds }) => { 195 + const reacted = userIds.includes(me); 196 + return ( 197 + <button 198 + className={`rounded-full border px-2 py-0.5 text-xs ${reacted ? 'border-[#5865f2] bg-[#5865f2]/20 text-[#d7ddff]' : 'border-[#4c4f56] bg-[#2b2d31] text-gray-200 hover:bg-[#35373c]'}`} 199 + key={emoji} 200 + onClick={() => onToggleReaction(message, emoji, reacted)} 201 + type="button" 202 + > 203 + {emoji} {userIds.length} 204 + </button> 205 + ); 206 + })} 207 + <button className="rounded-full border border-[#4c4f56] px-2 py-0.5 text-xs text-gray-300 hover:bg-[#35373c]" onClick={() => onReply(message)} type="button"> 208 + <Reply size={12} className="inline" /> Reply 209 + </button> 210 + <button className="rounded-full border border-[#4c4f56] px-2 py-0.5 text-xs text-gray-300 hover:bg-[#35373c]" onClick={() => onToggleReaction(message, '👍', message.reactions?.['👍']?.includes(me))} type="button"> 211 + + React 212 + </button> 213 + {replyTarget === message._id ? <span className="text-[11px] text-[#bdc3ff]">Reply target</span> : null} 214 + </div> 170 215 </div> 171 216 </article> 172 217 ); ··· 192 237 const [activeModal, setActiveModal] = useState(null); 193 238 const [createServerName, setCreateServerName] = useState(''); 194 239 const [peekUser, setPeekUser] = useState(null); 240 + const [replyingTo, setReplyingTo] = useState(null); 241 + const [showEmojiPicker, setShowEmojiPicker] = useState(false); 195 242 196 243 const [loginMode, setLoginMode] = useState('credentials'); 197 244 const [email, setEmail] = useState(''); ··· 215 262 }, [channels, selectedServerId]); 216 263 217 264 const currentMessages = useMemo(() => messages[selectedChannelId] || [], [messages, selectedChannelId]); 265 + const currentMessageMap = useMemo(() => Object.fromEntries(currentMessages.map((message) => [message._id, message])), [currentMessages]); 218 266 219 267 const currentMembers = useMemo(() => { 220 268 if (selectedServerId === '@me') return []; ··· 330 378 setMessages((prev) => { 331 379 const list = prev[packet.channel] || []; 332 380 if (list.find((m) => m._id === packet._id)) return prev; 333 - return { ...prev, [packet.channel]: [...list, packet].slice(-200) }; 381 + const nextMessages = [...list, packet].slice(-200); 382 + return { ...prev, [packet.channel]: enrichReplies(nextMessages) }; 334 383 }); 335 384 break; 336 385 case 'MessageUpdate': 337 386 setMessages((prev) => ({ 338 387 ...prev, 339 - [packet.channel]: (prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m)), 388 + [packet.channel]: enrichReplies((prev[packet.channel] || []).map((m) => (m._id === packet.id ? { ...m, ...packet.data } : m))), 340 389 })); 341 390 break; 342 391 case 'MessageDelete': ··· 501 550 } 502 551 }; 503 552 553 + const enrichReplies = (nextMessages) => 554 + nextMessages.map((message) => { 555 + const replyId = Array.isArray(message.replies) ? message.replies[0] : null; 556 + const replyMessage = replyId ? nextMessages.find((entry) => entry._id === replyId) : null; 557 + if (!replyMessage) return message; 558 + const replyAuthorId = typeof replyMessage.author === 'string' ? replyMessage.author : replyMessage.author?._id; 559 + 560 + return { 561 + ...message, 562 + replyMessage: { 563 + _id: replyMessage._id, 564 + content: replyMessage.content, 565 + authorId: replyAuthorId, 566 + authorUser: users[replyAuthorId] || (typeof replyMessage.author === 'object' ? replyMessage.author : null), 567 + }, 568 + }; 569 + }); 570 + 504 571 const fetchMessages = async (channelId) => { 505 572 if (!channelId || channelId === 'friends') return; 506 573 ··· 524 591 }); 525 592 } 526 593 527 - setMessages((prev) => ({ ...prev, [channelId]: payloadMessages.reverse().slice(-200) })); 594 + const orderedMessages = payloadMessages.reverse().slice(-200); 595 + setMessages((prev) => ({ ...prev, [channelId]: enrichReplies(orderedMessages) })); 528 596 preloadedChannelRef.current[channelId] = true; 529 597 } catch { 530 598 // no-op ··· 557 625 558 626 if (serverId === '@me') { 559 627 setSelectedChannelId('friends'); 628 + setReplyingTo(null); 629 + setShowEmojiPicker(false); 560 630 return; 561 631 } 562 632 ··· 568 638 569 639 const handleChannelSelect = (channelId) => { 570 640 setSelectedChannelId(channelId); 641 + setReplyingTo(null); 642 + setShowEmojiPicker(false); 571 643 fetchMessages(channelId); 572 644 }; 573 645 ··· 649 721 'Content-Type': 'application/json', 650 722 'x-session-token': auth.token, 651 723 }, 652 - body: JSON.stringify({ content, nonce: crypto.randomUUID() }), 724 + body: JSON.stringify({ 725 + content, 726 + nonce: crypto.randomUUID(), 727 + replies: replyingTo ? [replyingTo._id] : undefined, 728 + }), 653 729 }); 730 + setReplyingTo(null); 731 + setShowEmojiPicker(false); 654 732 } catch { 655 733 setInputText(content); 656 734 } 657 735 }; 658 736 737 + 738 + const toggleReaction = async (message, emoji, reacted) => { 739 + if (!message?._id || !selectedChannelId || selectedChannelId === 'friends') return; 740 + const encodedEmoji = encodeURIComponent(emoji); 741 + 742 + setMessages((prev) => { 743 + const nextList = (prev[selectedChannelId] || []).map((entry) => { 744 + if (entry._id !== message._id) return entry; 745 + const existing = entry.reactions?.[emoji] || []; 746 + const nextUserIds = reacted ? existing.filter((id) => id !== auth.userId) : [...new Set([...existing, auth.userId])]; 747 + 748 + return { 749 + ...entry, 750 + reactions: { 751 + ...(entry.reactions || {}), 752 + [emoji]: nextUserIds, 753 + }, 754 + }; 755 + }); 756 + 757 + return { ...prev, [selectedChannelId]: nextList }; 758 + }); 759 + 760 + try { 761 + await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages/${message._id}/reactions/${encodedEmoji}`, { 762 + method: reacted ? 'DELETE' : 'PUT', 763 + headers: { 'x-session-token': auth.token }, 764 + }); 765 + } catch { 766 + // no-op, websocket sync should self-heal 767 + } 768 + }; 769 + 770 + const addEmojiToComposer = (emoji) => { 771 + setInputText((prev) => `${prev}${emoji}`); 772 + setShowEmojiPicker(false); 773 + }; 774 + 659 775 const createServer = async () => { 660 776 if (!createServerName.trim()) return; 661 777 ··· 677 793 // no-op 678 794 } 679 795 }; 796 + 797 + const activeReply = replyingTo ? currentMessageMap[replyingTo._id] || replyingTo : null; 680 798 681 799 const currentChannelName = 682 800 selectedChannelId === 'friends' ? 'friends' : channels[selectedChannelId]?.name || 'select-a-channel'; ··· 871 989 </div> 872 990 ) : ( 873 991 currentMessages.map((message) => ( 874 - <Message cdnUrl={config.cdnUrl} channels={channels} key={message._id} me={auth.userId} message={message} onUserClick={openUserProfile} users={users} /> 992 + <Message 993 + cdnUrl={config.cdnUrl} 994 + channels={channels} 995 + key={message._id} 996 + me={auth.userId} 997 + message={message} 998 + onReply={setReplyingTo} 999 + onToggleReaction={toggleReaction} 1000 + onUserClick={openUserProfile} 1001 + replyTarget={replyingTo?._id} 1002 + users={users} 1003 + /> 875 1004 )) 876 1005 )} 877 1006 <div ref={messagesBottomRef} /> 878 1007 </section> 879 1008 880 1009 <footer className="border-t border-[#202225] p-4"> 1010 + {activeReply ? ( 1011 + <div className="mb-2 flex items-center justify-between gap-2 rounded-md bg-[#2b2d31] px-3 py-2 text-xs text-gray-300"> 1012 + <span className="truncate"> 1013 + Replying to {users[typeof activeReply.author === 'string' ? activeReply.author : activeReply.author?._id]?.username || 'Unknown user'}: {activeReply.content || 'Attachment / embed'} 1014 + </span> 1015 + <button className="rounded p-1 text-gray-400 hover:bg-[#3a3d42] hover:text-white" onClick={() => setReplyingTo(null)} type="button"> 1016 + <X size={13} /> 1017 + </button> 1018 + </div> 1019 + ) : null} 881 1020 <div className="flex items-center gap-2 rounded-lg bg-[#383a40] p-2"> 1021 + <div className="relative"> 1022 + <button 1023 + className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" 1024 + disabled={selectedChannelId === 'friends'} 1025 + onClick={() => setShowEmojiPicker((prev) => !prev)} 1026 + type="button" 1027 + > 1028 + <Smile size={16} /> 1029 + </button> 1030 + {showEmojiPicker && selectedChannelId !== 'friends' ? ( 1031 + <div className="absolute bottom-12 left-0 z-20 grid w-44 grid-cols-3 gap-1 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 1032 + {QUICK_EMOJIS.map((emoji) => ( 1033 + <button className="rounded p-1.5 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button"> 1034 + {emoji} 1035 + </button> 1036 + ))} 1037 + </div> 1038 + ) : null} 1039 + </div> 882 1040 <input 883 1041 className="flex-1 bg-transparent px-2 py-1 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none" 884 1042 disabled={selectedChannelId === 'friends'} ··· 887 1045 placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Message #${currentChannelName}`} 888 1046 value={inputText} 889 1047 /> 890 - <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" onClick={sendMessage}> 1048 + <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" onClick={sendMessage} type="button"> 891 1049 <Send size={16} /> 892 1050 </button> 893 1051 </div>