[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 channel behavior, DMs, and messaging UX

Fascinating Pistachio (Feb 14, 2026, 3:18 PM UTC) cc50b1c8 bd842dc4

+304 -37
+304 -37
src/App.jsx
··· 65 65 }; 66 66 67 67 const QUICK_EMOJIS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ”ฅ', '๐Ÿ˜ญ', '๐ŸŽ‰', '๐Ÿ˜ฎ', '๐Ÿ‘€', '๐Ÿค”']; 68 + const EXTENDED_EMOJIS = [ 69 + ...QUICK_EMOJIS, 70 + '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿคฃ', '๐Ÿ˜‰', '๐Ÿ™', '๐Ÿ‘', 'โœจ', '๐ŸŽฏ', '๐Ÿ’ฏ', '๐Ÿค', '๐Ÿ˜…', '๐Ÿ˜Ž', '๐Ÿฅณ', 'โœ…', 'โŒ', '๐ŸŽต', '๐Ÿ“Œ', '๐Ÿš€', '๐Ÿซก', '๐ŸคŒ', 71 + ]; 68 72 const MEMBER_RENDER_LIMIT = 250; 69 73 const MEMBER_HYDRATE_CHUNK = 400; 70 74 ··· 118 122 }); 119 123 }; 120 124 121 - const Avatar = ({ user, cdnUrl, size = 'md' }) => { 125 + const Avatar = ({ user, cdnUrl, size = 'md', animateOnHover = false, alwaysAnimate = false }) => { 122 126 const sizeMap = { 123 127 sm: 'h-8 w-8 text-xs', 124 128 md: 'h-10 w-10 text-sm', ··· 126 130 }; 127 131 128 132 const initials = (user?.username || '?').slice(0, 2).toUpperCase(); 129 - const src = getAvatarUrl(user, cdnUrl); 133 + const [isHovered, setIsHovered] = useState(false); 134 + const hasAnimatedAvatar = Boolean(user?.avatar?.animated); 135 + const src = (() => { 136 + if (!user?.avatar?._id) return null; 137 + const wantsAnimated = hasAnimatedAvatar && (alwaysAnimate || (animateOnHover && isHovered)); 138 + const formatQuery = wantsAnimated ? '&format=gif' : ''; 139 + return `${cdnUrl}/avatars/${user.avatar._id}?max_side=256${formatQuery}`; 140 + })(); 130 141 131 142 return ( 132 - <div className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`}> 143 + <div 144 + className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-[#313338] font-semibold text-gray-200 ${sizeMap[size]}`} 145 + onMouseEnter={() => setIsHovered(true)} 146 + onMouseLeave={() => setIsHovered(false)} 147 + > 133 148 {src ? <img alt={user?.username || 'User avatar'} className="block h-full w-full object-cover object-center" src={src} /> : initials} 134 149 </div> 135 150 ); ··· 198 213 } 199 214 } 200 215 201 - const Message = ({ message, users, channels, me, onUserClick, cdnUrl, onToggleReaction, onReply, replyTarget }) => { 216 + const Message = ({ message, users, channels, me, onUserClick, cdnUrl, onToggleReaction, onReply, replyTarget, onJumpToMessage, registerMessageRef }) => { 202 217 const authorId = typeof message.author === 'string' ? message.author : message.author?._id; 203 218 const author = users[authorId] || (typeof message.author === 'object' ? message.author : null) || { username: 'Unknown user' }; 204 219 const mine = me === authorId; ··· 206 221 const replyPreview = message.replyMessage; 207 222 208 223 return ( 209 - <article className="group flex gap-3 rounded px-4 py-2 hover:bg-[#2e3035]"> 224 + <article className="group flex gap-3 rounded px-4 py-2 hover:bg-[#2e3035]" ref={(node) => registerMessageRef(message._id, node)}> 210 225 <button className="mt-0.5" onClick={() => onUserClick(author, authorId)} type="button"> 211 - <Avatar cdnUrl={cdnUrl} user={author} /> 226 + <Avatar animateOnHover cdnUrl={cdnUrl} user={author} /> 212 227 </button> 213 228 <div className="min-w-0 flex-1"> 214 229 {replyPreview ? ( 215 230 <button 216 231 className="mb-1 flex max-w-full items-center gap-1 text-xs text-gray-400 hover:text-gray-200" 217 - onClick={() => onUserClick(replyPreview.authorUser || { username: 'Unknown user' }, replyPreview.authorId)} 232 + onClick={() => onJumpToMessage(replyPreview._id)} 218 233 type="button" 219 234 > 220 235 <Reply size={12} /> ··· 281 296 const [isMembersLoading, setIsMembersLoading] = useState(false); 282 297 const [replyingTo, setReplyingTo] = useState(null); 283 298 const [showEmojiPicker, setShowEmojiPicker] = useState(false); 299 + const [voiceNotice, setVoiceNotice] = useState(''); 284 300 285 301 const [loginMode, setLoginMode] = useState('credentials'); 286 302 const [email, setEmail] = useState(''); ··· 301 317 const canFetchMissingUsersRef = useRef(true); 302 318 const loggedMissingUserFetchErrorRef = useRef(false); 303 319 const wsReconnectTimeoutRef = useRef(null); 320 + const messageRefs = useRef({}); 304 321 305 322 const serverList = useMemo(() => Object.values(servers), [servers]); 306 323 ··· 309 326 return Object.values(channels).filter((ch) => ch.server === selectedServerId); 310 327 }, [channels, selectedServerId]); 311 328 329 + const directMessageChannels = useMemo( 330 + () => Object.values(channels).filter((channel) => channel?.channel_type === 'DirectMessage'), 331 + [channels], 332 + ); 333 + 312 334 const currentMessages = useMemo(() => messages[selectedChannelId] || [], [messages, selectedChannelId]); 313 335 const currentMessageMap = useMemo(() => Object.fromEntries(currentMessages.map((message) => [message._id, message])), [currentMessages]); 314 336 ··· 342 364 [users], 343 365 ); 344 366 367 + const selectedServer = servers[selectedServerId]; 368 + 369 + const customEmojis = useMemo(() => { 370 + if (!selectedServer || selectedServerId === '@me') return []; 371 + const source = selectedServer.emojis || selectedServer.emoji || []; 372 + if (Array.isArray(source)) { 373 + return source.map((entry) => ({ 374 + id: entry?._id || entry?.id, 375 + name: entry?.name || entry?._id || 'emoji', 376 + })); 377 + } 378 + 379 + return Object.entries(source).map(([id, value]) => ({ 380 + id, 381 + name: value?.name || id, 382 + })); 383 + }, [selectedServer, selectedServerId]); 384 + 385 + 386 + const peekMember = useMemo(() => { 387 + if (!peekUser?._id || selectedServerId === '@me') return null; 388 + return members[`${selectedServerId}:${peekUser._id}`] || null; 389 + }, [members, peekUser, selectedServerId]); 390 + 391 + const peekRoles = useMemo(() => { 392 + if (!peekMember || !selectedServer?.roles) return []; 393 + return (peekMember.roles || []).map((roleId) => selectedServer.roles?.[roleId]).filter(Boolean); 394 + }, [peekMember, selectedServer]); 395 + 396 + const peekBadges = useMemo(() => { 397 + if (!peekUser?.badges) return []; 398 + if (Array.isArray(peekUser.badges)) return peekUser.badges; 399 + if (typeof peekUser.badges === 'number') { 400 + const flagMap = { 401 + 1: 'Developer', 402 + 2: 'Translator', 403 + 4: 'Supporter', 404 + 8: 'Founder', 405 + 16: 'Platform Moderation', 406 + 32: 'Active Supporter', 407 + 64: 'Paw', 408 + }; 409 + return Object.entries(flagMap) 410 + .filter(([bit]) => (peekUser.badges & Number(bit)) === Number(bit)) 411 + .map(([, label]) => label); 412 + } 413 + if (typeof peekUser.badges === 'object') { 414 + return Object.entries(peekUser.badges) 415 + .filter(([, enabled]) => Boolean(enabled)) 416 + .map(([badge]) => badge); 417 + } 418 + return []; 419 + }, [peekUser]); 420 + 345 421 const isSameOriginApi = useMemo(() => { 346 422 try { 347 423 return new URL(config.apiUrl).origin === window.location.origin; ··· 665 741 messagesBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); 666 742 }, [currentMessages]); 667 743 744 + useEffect(() => { 745 + if (!voiceNotice) return undefined; 746 + const timeout = setTimeout(() => setVoiceNotice(''), 3500); 747 + return () => clearTimeout(timeout); 748 + }, [voiceNotice]); 749 + 668 750 const fetchMembers = async (serverId) => { 669 751 if (serverId === '@me') return; 670 752 ··· 784 866 return; 785 867 } 786 868 787 - const firstChannel = Object.values(channels).find((ch) => ch.server === serverId); 869 + const firstChannel = Object.values(channels).find((ch) => ch.server === serverId && ch.channel_type !== 'VoiceChannel') 870 + || Object.values(channels).find((ch) => ch.server === serverId); 788 871 setSelectedChannelId(firstChannel?._id || null); 789 872 if (firstChannel?._id) fetchMessages(firstChannel._id); 790 873 if (!preloadedMembersRef.current[serverId]) { ··· 794 877 }; 795 878 796 879 const handleChannelSelect = (channelId) => { 880 + const channel = channels[channelId]; 881 + if (channel?.channel_type === 'VoiceChannel') { 882 + setVoiceNotice("Ermine currently doesn't support voice channels."); 883 + return; 884 + } 885 + 886 + setVoiceNotice(''); 797 887 setSelectedChannelId(channelId); 798 888 setReplyingTo(null); 799 889 setShowEmojiPicker(false); 800 890 fetchMessages(channelId); 801 891 }; 802 892 893 + const openDmWithUser = async (userId) => { 894 + if (!userId || !auth.token) return; 895 + 896 + const existing = directMessageChannels.find((channel) => 897 + Array.isArray(channel?.recipients) && channel.recipients.includes(userId), 898 + ); 899 + 900 + if (existing?._id) { 901 + setSelectedServerId('@me'); 902 + handleChannelSelect(existing._id); 903 + return; 904 + } 905 + 906 + try { 907 + const res = await fetch(`${config.apiUrl}/users/${userId}/dm`, { 908 + method: 'GET', 909 + headers: { 'x-session-token': auth.token }, 910 + }); 911 + 912 + if (!res.ok) return; 913 + const channel = await res.json(); 914 + if (!channel?._id) return; 915 + 916 + setChannels((prev) => ({ ...prev, [channel._id]: channel })); 917 + setSelectedServerId('@me'); 918 + setSelectedChannelId(channel._id); 919 + fetchMessages(channel._id); 920 + } catch { 921 + // no-op 922 + } 923 + }; 924 + 925 + const jumpToMessage = (messageId) => { 926 + if (!messageId) return; 927 + const target = messageRefs.current[messageId]; 928 + if (!target) return; 929 + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 930 + target.classList.add('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); 931 + setTimeout(() => { 932 + target.classList.remove('ring-2', 'ring-[#5865f2]', 'bg-[#3a3f66]/40'); 933 + }, 1200); 934 + }; 935 + 936 + const registerMessageRef = (messageId, node) => { 937 + if (!messageId) return; 938 + if (node) { 939 + messageRefs.current[messageId] = node; 940 + } else { 941 + delete messageRefs.current[messageId]; 942 + } 943 + }; 944 + 803 945 const handleLogin = async (event) => { 804 946 event.preventDefault(); 805 947 setLoginError(''); ··· 880 1022 if (!content || !selectedChannelId || selectedChannelId === 'friends') return; 881 1023 882 1024 setInputText(''); 1025 + const nonce = crypto.randomUUID(); 1026 + const pendingId = `pending-${nonce}`; 1027 + const meUser = users[auth.userId] || { _id: auth.userId, username: 'You' }; 1028 + const optimisticMessage = { 1029 + _id: pendingId, 1030 + channel: selectedChannelId, 1031 + author: auth.userId, 1032 + user: meUser, 1033 + content, 1034 + createdAt: new Date().toISOString(), 1035 + reactions: {}, 1036 + replies: replyingTo ? [replyingTo._id] : undefined, 1037 + }; 1038 + 1039 + setMessages((prev) => { 1040 + const list = prev[selectedChannelId] || []; 1041 + return { ...prev, [selectedChannelId]: enrichReplies([...list, optimisticMessage].slice(-200)) }; 1042 + }); 883 1043 884 1044 try { 885 - await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages`, { 1045 + const res = await fetch(`${config.apiUrl}/channels/${selectedChannelId}/messages`, { 886 1046 method: 'POST', 887 1047 headers: { 888 1048 'Content-Type': 'application/json', ··· 890 1050 }, 891 1051 body: JSON.stringify({ 892 1052 content, 893 - nonce: crypto.randomUUID(), 1053 + nonce, 894 1054 replies: replyingTo ? [replyingTo._id] : undefined, 895 1055 }), 896 1056 }); 1057 + 1058 + if (!res.ok) throw new Error('Failed to send message'); 1059 + const postedMessage = await res.json(); 1060 + 1061 + if (postedMessage?._id) { 1062 + upsertUsersFromMessages([postedMessage]); 1063 + setMessages((prev) => { 1064 + const list = (prev[selectedChannelId] || []).map((entry) => (entry._id === pendingId ? postedMessage : entry)); 1065 + return { ...prev, [selectedChannelId]: enrichReplies(list) }; 1066 + }); 1067 + } 1068 + 897 1069 setReplyingTo(null); 898 1070 setShowEmojiPicker(false); 899 1071 } catch { 1072 + setMessages((prev) => ({ 1073 + ...prev, 1074 + [selectedChannelId]: (prev[selectedChannelId] || []).filter((entry) => entry._id !== pendingId), 1075 + })); 900 1076 setInputText(content); 901 1077 } 902 1078 }; ··· 939 1115 setShowEmojiPicker(false); 940 1116 }; 941 1117 1118 + const addCustomEmojiToComposer = (emojiName) => { 1119 + setInputText((prev) => `${prev}:${emojiName}:`); 1120 + setShowEmojiPicker(false); 1121 + }; 1122 + 942 1123 const createServer = async () => { 943 1124 if (!createServerName.trim()) return; 944 1125 ··· 963 1144 964 1145 const activeReply = replyingTo ? currentMessageMap[replyingTo._id] || replyingTo : null; 965 1146 966 - const currentChannelName = 967 - selectedChannelId === 'friends' ? 'friends' : channels[selectedChannelId]?.name || 'select-a-channel'; 1147 + const currentChannelName = (() => { 1148 + if (selectedChannelId === 'friends') return 'friends'; 1149 + const channel = channels[selectedChannelId]; 1150 + if (channel?.name) return channel.name; 1151 + if (channel?.channel_type === 'DirectMessage') { 1152 + const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 1153 + return users[recipientId]?.username || 'direct-message'; 1154 + } 1155 + return 'select-a-channel'; 1156 + })(); 968 1157 969 1158 if (view === 'loading') { 970 1159 return ( ··· 1063 1252 {peekUser ? ( 1064 1253 <Modal onClose={() => setPeekUser(null)} title="Member profile"> 1065 1254 <div className="flex items-center gap-3 rounded-lg bg-[#1e1f22] p-3"> 1066 - <Avatar cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 1255 + <Avatar alwaysAnimate cdnUrl={config.cdnUrl} size="lg" user={peekUser} /> 1067 1256 <div> 1068 1257 <div className="text-base font-semibold text-white">{peekUser?.username || 'Unknown user'}</div> 1069 1258 <div className="text-xs text-gray-400">#{peekUser?.discriminator || '0000'}</div> 1070 1259 </div> 1071 1260 </div> 1072 - <p className="text-xs text-gray-400">Ermine profile popout for stoat.chat members.</p> 1261 + 1262 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1263 + <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> 1265 + </div> 1266 + 1267 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1268 + <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Badges</p> 1269 + {peekBadges.length ? ( 1270 + <div className="flex flex-wrap gap-1.5"> 1271 + {peekBadges.map((badge) => ( 1272 + <span className="rounded bg-[#5865f2]/20 px-2 py-1 text-xs text-[#d7ddff]" key={badge}>{badge}</span> 1273 + ))} 1274 + </div> 1275 + ) : ( 1276 + <p className="text-xs text-gray-400">No badges</p> 1277 + )} 1278 + </div> 1279 + 1280 + <div className="space-y-1 rounded-md bg-[#1e1f22] p-3"> 1281 + <p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Roles</p> 1282 + {peekRoles.length ? ( 1283 + <div className="flex flex-wrap gap-1.5"> 1284 + {peekRoles.map((role) => ( 1285 + <span className="rounded bg-[#3a3d42] px-2 py-1 text-xs text-gray-200" key={role.name}>{role.name}</span> 1286 + ))} 1287 + </div> 1288 + ) : ( 1289 + <p className="text-xs text-gray-400">No server roles</p> 1290 + )} 1291 + </div> 1073 1292 </Modal> 1074 1293 ) : null} 1075 1294 ··· 1101 1320 </div> 1102 1321 <div className="flex-1 overflow-y-auto p-2"> 1103 1322 {selectedServerId === '@me' ? ( 1104 - <button className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === 'friends' ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} onClick={() => handleChannelSelect('friends')}> 1105 - <Users size={16} /> Friends 1106 - </button> 1107 - ) : ( 1108 - channelList.map((channel) => ( 1109 - <button className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} key={channel._id} onClick={() => handleChannelSelect(channel._id)}> 1110 - <Hash size={16} /> 1111 - <span className="truncate">{channel.name || 'channel'}</span> 1323 + <> 1324 + <button className={`mb-1 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === 'friends' ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} onClick={() => handleChannelSelect('friends')}> 1325 + <Users size={16} /> Friends 1112 1326 </button> 1113 - )) 1327 + {directMessageChannels.map((channel) => { 1328 + const recipientId = (channel.recipients || []).find((id) => id !== auth.userId); 1329 + const recipient = users[recipientId]; 1330 + const label = recipient?.username || channel.name || 'Direct Message'; 1331 + return ( 1332 + <button className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} key={channel._id} onClick={() => handleChannelSelect(channel._id)}> 1333 + <MessageSquare size={16} /> 1334 + <span className="truncate">{label}</span> 1335 + </button> 1336 + ); 1337 + })} 1338 + </> 1339 + ) : ( 1340 + channelList.map((channel) => { 1341 + const isVoice = channel?.channel_type === 'VoiceChannel'; 1342 + return ( 1343 + <button 1344 + className={`mb-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${isVoice ? 'cursor-not-allowed text-gray-500 hover:bg-[#2f3237]' : selectedChannelId === channel._id ? 'bg-[#404249] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-white'}`} 1345 + key={channel._id} 1346 + onClick={() => handleChannelSelect(channel._id)} 1347 + title={isVoice ? "Ermine currently doesn't support voice channels." : channel.name} 1348 + > 1349 + {isVoice ? <Users size={16} /> : <Hash size={16} />} 1350 + <span className="truncate">{channel.name || 'channel'}</span> 1351 + </button> 1352 + ); 1353 + }) 1114 1354 )} 1115 1355 </div> 1116 1356 1117 1357 <div className="border-t border-[#202225] p-2"> 1118 1358 <div className="flex items-center justify-between rounded bg-[#232428] p-2"> 1119 1359 <div className="flex items-center gap-2"> 1120 - <Avatar cdnUrl={config.cdnUrl} size="sm" user={users[auth.userId]} /> 1360 + <Avatar animateOnHover cdnUrl={config.cdnUrl} size="sm" user={users[auth.userId]} /> 1121 1361 <div className="min-w-0"> 1122 1362 <div className="truncate text-xs font-semibold text-white">{users[auth.userId]?.username || 'Connected'}</div> 1123 1363 <div className="text-[10px] uppercase tracking-wide text-gray-400">{status}</div> ··· 1133 1373 <main className="flex min-w-0 flex-1 flex-col bg-[#313338]"> 1134 1374 <header className="flex items-center justify-between border-b border-[#202225] px-4 py-3"> 1135 1375 <div className="flex items-center gap-2 text-sm font-semibold text-white"> 1136 - <Hash size={17} className="text-gray-400" /> 1376 + {channels[selectedChannelId]?.channel_type === 'DirectMessage' ? <MessageSquare size={17} className="text-gray-400" /> : <Hash size={17} className="text-gray-400" />} 1137 1377 <span>{currentChannelName}</span> 1138 1378 </div> 1139 1379 <div className="text-xs text-gray-400">Ermine for stoat.chat</div> 1140 1380 </header> 1141 1381 1382 + {voiceNotice ? ( 1383 + <div className="mx-4 mt-3 rounded-md border border-[#665200] bg-[#5c4a00]/25 px-3 py-2 text-xs text-yellow-200"> 1384 + {voiceNotice} 1385 + </div> 1386 + ) : null} 1387 + 1142 1388 <section className="flex-1 overflow-y-auto py-2"> 1143 1389 {selectedChannelId === 'friends' ? ( 1144 1390 <div className="space-y-2 p-4"> 1145 1391 <h2 className="text-xs font-bold uppercase tracking-wide text-gray-400">Friends ({friends.length})</h2> 1146 1392 {friends.length === 0 ? <p className="text-sm text-gray-400">No friends available yet.</p> : null} 1147 1393 {friends.map((friend) => ( 1148 - <button className="flex w-full items-center gap-3 rounded bg-[#2b2d31] p-3 text-left hover:bg-[#35373c]" key={friend._id} onClick={() => openUserProfile(friend, friend._id)}> 1149 - <Avatar cdnUrl={config.cdnUrl} user={friend} /> 1394 + <button className="flex w-full items-center gap-3 rounded bg-[#2b2d31] p-3 text-left hover:bg-[#35373c]" key={friend._id} onClick={() => openDmWithUser(friend._id)}> 1395 + <Avatar animateOnHover cdnUrl={config.cdnUrl} user={friend} /> 1150 1396 <div className="min-w-0"> 1151 1397 <div className="truncate text-sm font-semibold text-white">{friend.username}</div> 1152 1398 <div className="text-xs text-gray-400">Direct message</div> ··· 1165 1411 onReply={setReplyingTo} 1166 1412 onToggleReaction={toggleReaction} 1167 1413 onUserClick={openUserProfile} 1414 + onJumpToMessage={jumpToMessage} 1415 + registerMessageRef={registerMessageRef} 1168 1416 replyTarget={replyingTo?._id} 1169 1417 users={users} 1170 1418 /> ··· 1195 1443 <Smile size={16} /> 1196 1444 </button> 1197 1445 {showEmojiPicker && selectedChannelId !== 'friends' ? ( 1198 - <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"> 1199 - {QUICK_EMOJIS.map((emoji) => ( 1200 - <button className="rounded p-1.5 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button"> 1201 - {emoji} 1202 - </button> 1203 - ))} 1446 + <div className="absolute bottom-12 left-0 z-20 w-60 rounded-lg border border-[#4c4f56] bg-[#232428] p-2 shadow-2xl"> 1447 + <p className="mb-1 text-[10px] uppercase tracking-wide text-gray-500">Emoji</p> 1448 + <div className="grid max-h-36 grid-cols-6 gap-1 overflow-y-auto"> 1449 + {EXTENDED_EMOJIS.map((emoji) => ( 1450 + <button className="rounded p-1 text-lg hover:bg-[#3a3d42]" key={emoji} onClick={() => addEmojiToComposer(emoji)} type="button"> 1451 + {emoji} 1452 + </button> 1453 + ))} 1454 + </div> 1455 + {customEmojis.length ? ( 1456 + <> 1457 + <p className="mb-1 mt-2 text-[10px] uppercase tracking-wide text-gray-500">Server emojis</p> 1458 + <div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto"> 1459 + {customEmojis.map((emoji) => ( 1460 + <button className="rounded bg-[#303239] px-1.5 py-1 text-xs text-gray-200 hover:bg-[#3a3d42]" key={emoji.id || emoji.name} onClick={() => addCustomEmojiToComposer(emoji.name)} type="button"> 1461 + :{emoji.name}: 1462 + </button> 1463 + ))} 1464 + </div> 1465 + </> 1466 + ) : null} 1204 1467 </div> 1205 1468 ) : null} 1206 1469 </div> ··· 1208 1471 className="flex-1 bg-transparent px-2 py-1 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-none" 1209 1472 disabled={selectedChannelId === 'friends'} 1210 1473 onChange={(e) => setInputText(e.target.value)} 1211 - onKeyDown={(e) => e.key === 'Enter' && sendMessage()} 1212 - placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Message #${currentChannelName}`} 1474 + onKeyDown={(e) => { 1475 + if (e.key === 'Enter' && !e.shiftKey) { 1476 + e.preventDefault(); 1477 + sendMessage(); 1478 + } 1479 + }} 1480 + placeholder={selectedChannelId === 'friends' ? 'Select a text channel to chat.' : `Enter message in #${currentChannelName}`} 1213 1481 value={inputText} 1214 1482 /> 1215 1483 <button className="rounded p-2 text-gray-300 hover:bg-[#4b4d55] hover:text-white" onClick={sendMessage} type="button"> ··· 1227 1495 {hiddenMembersCount > 0 ? <p className="px-2 py-1 text-[11px] text-gray-500">Showing first {currentMembers.length} members for responsiveness.</p> : null} 1228 1496 {currentMembers.map((member) => ( 1229 1497 <button className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-[#35373c]" key={member._id.user} onClick={() => openUserProfile(member.user, member._id.user)}> 1230 - <Avatar cdnUrl={config.cdnUrl} size="sm" user={member.user} /> 1498 + <Avatar animateOnHover cdnUrl={config.cdnUrl} size="sm" user={member.user} /> 1231 1499 <div className="min-w-0"> 1232 1500 <div className="truncate text-sm text-gray-200">{member.nickname || member.user.username}</div> 1233 1501 <div className="flex items-center gap-1 text-[11px] text-gray-500"> ··· 1249 1517 </AppErrorBoundary> 1250 1518 ); 1251 1519 } 1252 -