Add custom emojis
All checks were successful
Build and Release / build-and-release (push) Successful in 10m4s
All checks were successful
Build and Release / build-and-release (push) Successful in 10m4s
This commit is contained in:
@@ -513,6 +513,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const [mentionQuery, setMentionQuery] = useState(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
||||
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesContainerRef = useRef(null);
|
||||
@@ -536,6 +537,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
|
||||
const roles = useQuery(api.roles.list, channelType !== 'dm' ? {} : "skip") || [];
|
||||
const myPermissions = useQuery(api.roles.getMyPermissions, currentUserId ? { userId: currentUserId } : "skip");
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
|
||||
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||
api.messages.list,
|
||||
@@ -765,6 +767,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setEditingMessage(null);
|
||||
setMentionQuery(null);
|
||||
setUnreadDividerTimestamp(null);
|
||||
setReactionPickerMsgId(null);
|
||||
onTogglePinned();
|
||||
}, [channelId]);
|
||||
|
||||
@@ -1013,7 +1016,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
if (!match) return;
|
||||
|
||||
const name = match[1];
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
const emoji = customEmojis.find(e => e.name === name) || AllEmojis.find(e => e.name === name);
|
||||
if (!emoji) return;
|
||||
|
||||
const img = document.createElement('img');
|
||||
@@ -1278,7 +1281,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
deleteMessageMutation({ id: msg.id, userId: currentUserId });
|
||||
break;
|
||||
case 'reaction':
|
||||
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
|
||||
setReactionPickerMsgId(msg.id);
|
||||
break;
|
||||
}
|
||||
setContextMenu(null);
|
||||
@@ -1382,6 +1385,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
isMentioned={isMentioned}
|
||||
isOwner={isOwner}
|
||||
roles={roles}
|
||||
customEmojis={customEmojis}
|
||||
isEditing={editingMessage?.id === msg.id}
|
||||
isHovered={hoveredMessageId === msg.id}
|
||||
editInput={editInput}
|
||||
@@ -1389,7 +1393,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
onHover={() => setHoveredMessageId(msg.id)}
|
||||
onLeave={() => setHoveredMessageId(null)}
|
||||
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
|
||||
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
|
||||
onAddReaction={(emoji) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }}
|
||||
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
|
||||
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
|
||||
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }}
|
||||
@@ -1412,6 +1416,22 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
</div>
|
||||
</div>
|
||||
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
||||
{reactionPickerMsgId && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 999 }} onClick={() => setReactionPickerMsgId(null)}>
|
||||
<div style={{ position: 'absolute', right: '80px', top: '50%', transform: 'translateY(-50%)' }} onClick={(e) => e.stopPropagation()}>
|
||||
<GifPicker
|
||||
initialTab="Emoji"
|
||||
onSelect={(data) => {
|
||||
if (typeof data !== 'string' && data.name) {
|
||||
addReaction({ messageId: reactionPickerMsgId, userId: currentUserId, emoji: data.name });
|
||||
}
|
||||
setReactionPickerMsgId(null);
|
||||
}}
|
||||
onClose={() => setReactionPickerMsgId(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inputContextMenu && <InputContextMenu x={inputContextMenu.x} y={inputContextMenu.y} onClose={() => setInputContextMenu(null)} onPaste={async () => {
|
||||
try {
|
||||
if (inputDivRef.current) inputDivRef.current.focus();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { useConvex, useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const EmojiItem = ({ emoji, onSelect }) => (
|
||||
@@ -93,11 +93,12 @@ const GifContent = ({ search, results, categories, onSelect, onCategoryClick })
|
||||
);
|
||||
};
|
||||
|
||||
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory }) => {
|
||||
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory, customEmojis = [] }) => {
|
||||
if (search) {
|
||||
const filtered = AllEmojis
|
||||
.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
|
||||
.slice(0, 100);
|
||||
const q = search.toLowerCase().replace(/:/g, '');
|
||||
const customFiltered = customEmojis.filter(e => e.name.toLowerCase().includes(q));
|
||||
const builtinFiltered = AllEmojis.filter(e => e.name.toLowerCase().includes(q));
|
||||
const filtered = [...customFiltered, ...builtinFiltered].slice(0, 100);
|
||||
return (
|
||||
<div className="emoji-grid" style={{ height: '100%' }}>
|
||||
<div style={emojiGridStyle}>
|
||||
@@ -109,51 +110,67 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
|
||||
);
|
||||
}
|
||||
|
||||
const CategoryHeader = ({ name, collapsed }) => (
|
||||
<div
|
||||
onClick={() => toggleCategory(name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '8px',
|
||||
padding: '4px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--header-secondary)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<h3 style={{
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 700,
|
||||
margin: 0
|
||||
}}>
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="emoji-grid" style={{ height: '100%' }}>
|
||||
{customEmojis.length > 0 && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<CategoryHeader name="Custom" collapsed={collapsedCategories['Custom']} />
|
||||
{!collapsedCategories['Custom'] && (
|
||||
<div style={emojiGridStyle}>
|
||||
{customEmojis.map((emoji) => (
|
||||
<EmojiItem key={emoji._id || emoji.name} emoji={emoji} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
|
||||
<div key={category} style={{ marginBottom: '8px' }}>
|
||||
<div
|
||||
onClick={() => toggleCategory(category)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '8px',
|
||||
padding: '4px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--header-secondary)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<h3 style={{
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 700,
|
||||
margin: 0
|
||||
}}>
|
||||
{category}
|
||||
</h3>
|
||||
</div>
|
||||
<CategoryHeader name={category} collapsed={collapsedCategories[category]} />
|
||||
{!collapsedCategories[category] && (
|
||||
<div style={emojiGridStyle}>
|
||||
{emojis.map((emoji, idx) => (
|
||||
@@ -181,6 +198,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const convex = useConvex();
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
|
||||
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
|
||||
const setActiveTab = (tab) => {
|
||||
@@ -314,7 +332,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
) : activeTab === 'GIFs' ? (
|
||||
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
|
||||
) : (
|
||||
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} />
|
||||
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} customEmojis={customEmojis} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,9 +60,11 @@ export const formatMentions = (text, roles) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const formatEmojis = (text) => {
|
||||
export const formatEmojis = (text, customEmojis = []) => {
|
||||
if (!text) return '';
|
||||
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return ``;
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
return emoji ? `` : match;
|
||||
});
|
||||
@@ -91,12 +93,17 @@ export const parseSystemMessage = (content) => {
|
||||
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
|
||||
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
|
||||
|
||||
const getReactionIcon = (name) => {
|
||||
const getReactionIcon = (name, customEmojis = []) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return custom.src;
|
||||
switch (name) {
|
||||
case 'thumbsup': return thumbsupIcon;
|
||||
case 'heart': return heartIcon;
|
||||
case 'fire': return fireIcon;
|
||||
default: return heartIcon;
|
||||
default: {
|
||||
const builtin = AllEmojis.find(e => e.name === name);
|
||||
return builtin ? builtin.src : heartIcon;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,7 +142,7 @@ const createMarkdownComponents = (openExternal) => ({
|
||||
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
|
||||
img: ({ node, alt, src, ...props }) => {
|
||||
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
|
||||
return <img src={src} alt={alt} style={{ width: '22px', height: '22px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
|
||||
return <img src={src} alt={alt} style={{ width: '48px', height: '48px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
|
||||
}
|
||||
return <img alt={alt} src={src} {...props} />;
|
||||
},
|
||||
@@ -189,6 +196,7 @@ const MessageItem = React.memo(({
|
||||
editInput,
|
||||
username,
|
||||
roles,
|
||||
customEmojis,
|
||||
onHover,
|
||||
onLeave,
|
||||
onContextMenu,
|
||||
@@ -233,7 +241,7 @@ const MessageItem = React.memo(({
|
||||
<>
|
||||
{!isGif && !isDirectVideo && (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
||||
{formatEmojis(formatMentions(msg.content, roles))}
|
||||
{formatEmojis(formatMentions(msg.content, roles), customEmojis)}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
|
||||
@@ -250,7 +258,7 @@ const MessageItem = React.memo(({
|
||||
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
||||
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'hsla(240, 4%, 60.784%, 0.078)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
||||
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={null} />
|
||||
<ColoredIcon src={getReactionIcon(emojiName, customEmojis)} size="16px" color={null} />
|
||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -385,7 +393,8 @@ const MessageItem = React.memo(({
|
||||
prevProps.showDateDivider === nextProps.showDateDivider &&
|
||||
prevProps.showUnreadDivider === nextProps.showUnreadDivider &&
|
||||
prevProps.isMentioned === nextProps.isMentioned &&
|
||||
prevProps.roles === nextProps.roles
|
||||
prevProps.roles === nextProps.roles &&
|
||||
prevProps.customEmojis === nextProps.customEmojis
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { AllEmojis } from '../assets/emojis';
|
||||
import AvatarCropModal from './AvatarCropModal';
|
||||
import Cropper from 'react-easy-crop';
|
||||
|
||||
function getCroppedEmojiImg(imageSrc, pixelCrop, rotation, flipH, flipV) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.translate(64, 64);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
|
||||
ctx.translate(-64, -64);
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
|
||||
0, 0, 128, 128
|
||||
);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return reject(new Error('Canvas toBlob failed'));
|
||||
resolve(blob);
|
||||
}, 'image/png');
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = imageSrc;
|
||||
});
|
||||
}
|
||||
|
||||
const TIMEOUT_OPTIONS = [
|
||||
{ value: 60, label: '1 min' },
|
||||
@@ -26,6 +58,26 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
userId ? { userId } : "skip"
|
||||
) || {};
|
||||
|
||||
// Custom emojis
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
const [showEmojiModal, setShowEmojiModal] = useState(false);
|
||||
const [emojiPreviewUrl, setEmojiPreviewUrl] = useState(null);
|
||||
const [emojiName, setEmojiName] = useState('');
|
||||
const [emojiFile, setEmojiFile] = useState(null);
|
||||
const [emojiUploading, setEmojiUploading] = useState(false);
|
||||
const [emojiError, setEmojiError] = useState('');
|
||||
const emojiFileInputRef = useRef(null);
|
||||
const [emojiCrop, setEmojiCrop] = useState({ x: 0, y: 0 });
|
||||
const [emojiZoom, setEmojiZoom] = useState(1);
|
||||
const [emojiRotation, setEmojiRotation] = useState(0);
|
||||
const [emojiFlipH, setEmojiFlipH] = useState(false);
|
||||
const [emojiFlipV, setEmojiFlipV] = useState(false);
|
||||
const [emojiCroppedAreaPixels, setEmojiCroppedAreaPixels] = useState(null);
|
||||
|
||||
const onEmojiCropComplete = useCallback((_croppedArea, croppedPixels) => {
|
||||
setEmojiCroppedAreaPixels(croppedPixels);
|
||||
}, []);
|
||||
|
||||
// Server settings
|
||||
const serverSettings = useQuery(api.serverSettings.get);
|
||||
const channels = useQuery(api.channels.list) || [];
|
||||
@@ -44,10 +96,18 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
const iconInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showEmojiModal) {
|
||||
handleEmojiModalClose();
|
||||
} else if (!showIconCropModal) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
}, [onClose, showEmojiModal, showIconCropModal]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (serverSettings) {
|
||||
@@ -171,6 +231,88 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
|
||||
const currentIconUrl = iconPreview || serverSettings?.iconUrl;
|
||||
|
||||
const handleEmojiFileSelect = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const name = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, '').substring(0, 32);
|
||||
setEmojiFile(file);
|
||||
setEmojiName(name || 'emoji');
|
||||
setEmojiPreviewUrl(URL.createObjectURL(file));
|
||||
setEmojiError('');
|
||||
setShowEmojiModal(true);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleEmojiModalClose = () => {
|
||||
setShowEmojiModal(false);
|
||||
if (emojiPreviewUrl) URL.revokeObjectURL(emojiPreviewUrl);
|
||||
setEmojiPreviewUrl(null);
|
||||
setEmojiFile(null);
|
||||
setEmojiName('');
|
||||
setEmojiError('');
|
||||
setEmojiCrop({ x: 0, y: 0 });
|
||||
setEmojiZoom(1);
|
||||
setEmojiRotation(0);
|
||||
setEmojiFlipH(false);
|
||||
setEmojiFlipV(false);
|
||||
setEmojiCroppedAreaPixels(null);
|
||||
};
|
||||
|
||||
const handleEmojiUpload = async () => {
|
||||
if (!userId || !emojiFile || !emojiName.trim()) return;
|
||||
setEmojiError('');
|
||||
const name = emojiName.trim();
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
||||
setEmojiError('Name can only contain letters, numbers, and underscores');
|
||||
return;
|
||||
}
|
||||
if (name.length < 2 || name.length > 32) {
|
||||
setEmojiError('Name must be between 2 and 32 characters');
|
||||
return;
|
||||
}
|
||||
if (AllEmojis.find(e => e.name === name)) {
|
||||
setEmojiError(`"${name}" conflicts with a built-in emoji`);
|
||||
return;
|
||||
}
|
||||
if (customEmojis.find(e => e.name === name)) {
|
||||
setEmojiError(`"${name}" already exists as a custom emoji`);
|
||||
return;
|
||||
}
|
||||
|
||||
setEmojiUploading(true);
|
||||
try {
|
||||
let fileToUpload = emojiFile;
|
||||
if (emojiCroppedAreaPixels && emojiPreviewUrl) {
|
||||
const blob = await getCroppedEmojiImg(emojiPreviewUrl, emojiCroppedAreaPixels, emojiRotation, emojiFlipH, emojiFlipV);
|
||||
fileToUpload = new File([blob], 'emoji.png', { type: 'image/png' });
|
||||
}
|
||||
const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': fileToUpload.type },
|
||||
body: fileToUpload,
|
||||
});
|
||||
const { storageId } = await res.json();
|
||||
await convex.mutation(api.customEmojis.upload, { userId, name, storageId });
|
||||
handleEmojiModalClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to upload emoji:', e);
|
||||
setEmojiError(e.message || 'Failed to upload emoji');
|
||||
} finally {
|
||||
setEmojiUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiDelete = async (emojiId) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
await convex.mutation(api.customEmojis.remove, { userId, emojiId });
|
||||
} catch (e) {
|
||||
console.error('Failed to delete emoji:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
try {
|
||||
const newRole = await convex.mutation(api.roles.create, {
|
||||
@@ -224,7 +366,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
<div style={{ fontSize: '12px', fontWeight: '700', color: 'var(--text-muted)', marginBottom: '6px', textTransform: 'uppercase' }}>
|
||||
Server Settings
|
||||
</div>
|
||||
{['Overview', 'Roles', 'Members'].map(tab => (
|
||||
{['Overview', 'Emoji', 'Roles', 'Members'].map(tab => (
|
||||
<div
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -367,8 +509,94 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEmojiTab = () => (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p style={{ color: 'var(--header-secondary)', fontSize: 14, margin: '0 0 16px' }}>
|
||||
Add custom emoji that anyone can use in this server.
|
||||
</p>
|
||||
{myPermissions.manage_channels && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => emojiFileInputRef.current?.click()}
|
||||
style={{
|
||||
backgroundColor: '#5865F2', color: '#fff', border: 'none',
|
||||
borderRadius: 3, padding: '8px 16px', cursor: 'pointer',
|
||||
fontWeight: 600, fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Upload Emoji
|
||||
</button>
|
||||
<input
|
||||
ref={emojiFileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.gif"
|
||||
onChange={handleEmojiFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji table */}
|
||||
<div style={{ borderTop: '1px solid var(--border-subtle)' }}>
|
||||
{/* Table header */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '48px 1fr 1fr 40px',
|
||||
padding: '8px 12px', alignItems: 'center',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
}}>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase' }}>Image</span>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase' }}>Name</span>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase' }}>Uploaded By</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{customEmojis.length === 0 ? (
|
||||
<div style={{ color: 'var(--header-secondary)', textAlign: 'center', padding: '40px 0' }}>
|
||||
No custom emojis yet
|
||||
</div>
|
||||
) : (
|
||||
customEmojis.map(emoji => (
|
||||
<div
|
||||
key={emoji._id}
|
||||
className="emoji-table-row"
|
||||
style={{
|
||||
display: 'grid', gridTemplateColumns: '48px 1fr 1fr 40px',
|
||||
padding: '8px 12px', alignItems: 'center',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<img src={emoji.src} alt={emoji.name} style={{ width: 32, height: 32, objectFit: 'contain' }} />
|
||||
<span style={{ color: 'var(--header-primary)', fontSize: 15 }}>:{emoji.name}:</span>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{emoji.uploadedByUsername}</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{myPermissions.manage_channels && (
|
||||
<button
|
||||
onClick={() => handleEmojiDelete(emoji._id)}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 16, padding: '4px 8px',
|
||||
borderRadius: 4, opacity: 0.5, transition: 'opacity 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = '#ed4245'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.color = 'var(--header-secondary)'; }}
|
||||
title="Delete emoji"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'Emoji': return renderEmojiTab();
|
||||
case 'Roles': return renderRolesTab();
|
||||
case 'Members': return renderMembersTab();
|
||||
default: return (
|
||||
@@ -552,6 +780,223 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
cropShape="rect"
|
||||
/>
|
||||
)}
|
||||
{showEmojiModal && emojiPreviewUrl && (
|
||||
<div
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
|
||||
width: 580, maxWidth: '90vw', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Modal header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '16px 16px 0',
|
||||
}}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
|
||||
<button
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal body */}
|
||||
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
|
||||
{/* Left: Cropper + toolbar + zoom */}
|
||||
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 240, height: 240, position: 'relative',
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Cropper
|
||||
image={emojiPreviewUrl}
|
||||
crop={emojiCrop}
|
||||
zoom={emojiZoom}
|
||||
rotation={emojiRotation}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
onCropChange={setEmojiCrop}
|
||||
onZoomChange={setEmojiZoom}
|
||||
onCropComplete={onEmojiCropComplete}
|
||||
style={{
|
||||
containerStyle: { width: 240, height: 240 },
|
||||
mediaStyle: {
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: rotate + flip */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)}
|
||||
title="Rotate left"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiRotation((r) => (r + 90) % 360)}
|
||||
title="Rotate right"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiFlipH((f) => !f)}
|
||||
title="Flip horizontal"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
|
||||
border: emojiFlipH ? '1px solid #5865F2' : 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiFlipV((f) => !f)}
|
||||
title="Flip vertical"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
|
||||
border: emojiFlipV ? '1px solid #5865F2' : 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={emojiZoom}
|
||||
onChange={(e) => setEmojiZoom(Number(e.target.value))}
|
||||
className="avatar-crop-slider"
|
||||
/>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Reaction preview + Name + Finish */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Reaction pill preview */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Preview
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
backgroundColor: 'rgba(88, 101, 242, 0.15)',
|
||||
border: '1px solid var(--brand-experiment, #5865F2)',
|
||||
borderRadius: 8, padding: '2px 6px', cursor: 'default',
|
||||
}}>
|
||||
<img
|
||||
src={emojiPreviewUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: 16, height: 16, objectFit: 'contain',
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji name input */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Emoji name <span style={{ color: '#ed4245' }}>*</span>
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={emojiName}
|
||||
onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }}
|
||||
maxLength={32}
|
||||
style={{
|
||||
width: '100%', padding: '10px 32px 10px 10px',
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
borderRadius: 4, color: 'var(--header-primary)', fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{emojiName && (
|
||||
<button
|
||||
onClick={() => setEmojiName('')}
|
||||
style={{
|
||||
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 14, padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{emojiError && (
|
||||
<div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Finish button */}
|
||||
<button
|
||||
onClick={handleEmojiUpload}
|
||||
disabled={emojiUploading || !emojiName.trim()}
|
||||
style={{
|
||||
backgroundColor: '#5865F2', color: '#fff', border: 'none',
|
||||
borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600, fontSize: 14, width: '100%',
|
||||
opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{emojiUploading ? 'Uploading...' : 'Finish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user