Add custom emojis
All checks were successful
Build and Release / build-and-release (push) Successful in 10m4s

This commit is contained in:
Bryan1029384756
2026-02-16 21:33:37 -06:00
parent 2b9fd4e7e0
commit b63c7a71e1
10 changed files with 663 additions and 73 deletions

11
TODO.md
View File

@@ -1,18 +1,9 @@
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
- Fix green status not updating correctly
# Future
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.
- Add photo / video albums like Commit https://commet.chat/
For the main admin can we keep track of how much space a user is taking in the database.
Is it possible with large files we do a torrent or peer to peer based file sharing. So users that want to share really large files dont store it on our servers but share it using peer to peer.
Lets allow custom emojis in the server. So if you go to the server settings on discord you can upload custom emojies. You put a image, a emoji name, and then you can use it in the chat like :emoji_name:. Discord resizes the image to a max of 32px thats either width or height depending on the image aspect ratio. The allow transparent background images and gif's. We dont allow duplicate names to existing emojis that we already have by default or custom emojies

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/electron",
"private": true,
"version": "1.0.20",
"version": "1.0.21",
"description": "Discord Clone - Electron app",
"author": "Moyettes",
"type": "module",

View File

@@ -12,6 +12,7 @@ import type * as auth from "../auth.js";
import type * as categories from "../categories.js";
import type * as channelKeys from "../channelKeys.js";
import type * as channels from "../channels.js";
import type * as customEmojis from "../customEmojis.js";
import type * as dms from "../dms.js";
import type * as files from "../files.js";
import type * as gifs from "../gifs.js";
@@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{
categories: typeof categories;
channelKeys: typeof channelKeys;
channels: typeof channels;
customEmojis: typeof customEmojis;
dms: typeof dms;
files: typeof files;
gifs: typeof gifs;

98
convex/customEmojis.ts Normal file
View File

@@ -0,0 +1,98 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { getRolesForUser } from "./roles";
import { getPublicStorageUrl } from "./storageUrl";
export const list = query({
args: {},
returns: v.any(),
handler: async (ctx) => {
const emojis = await ctx.db.query("customEmojis").collect();
const results = await Promise.all(
emojis.map(async (emoji) => {
const src = await getPublicStorageUrl(ctx, emoji.storageId);
const user = await ctx.db.get(emoji.uploadedBy);
return {
_id: emoji._id,
name: emoji.name,
src,
createdAt: emoji.createdAt,
uploadedByUsername: user?.username || "Unknown",
};
})
);
return results.filter((e) => e.src !== null);
},
});
export const upload = mutation({
args: {
userId: v.id("userProfiles"),
name: v.string(),
storageId: v.id("_storage"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Permission check
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
);
if (!canManage) {
throw new Error("You don't have permission to manage emojis");
}
// Validate name format
const name = args.name.trim();
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
throw new Error("Emoji name can only contain letters, numbers, and underscores");
}
if (name.length < 2 || name.length > 32) {
throw new Error("Emoji name must be between 2 and 32 characters");
}
// Check for duplicate name among custom emojis
const existing = await ctx.db
.query("customEmojis")
.withIndex("by_name", (q) => q.eq("name", name))
.first();
if (existing) {
throw new Error(`A custom emoji named "${name}" already exists`);
}
await ctx.db.insert("customEmojis", {
name,
storageId: args.storageId,
uploadedBy: args.userId,
createdAt: Date.now(),
});
return null;
},
});
export const remove = mutation({
args: {
userId: v.id("userProfiles"),
emojiId: v.id("customEmojis"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Permission check
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
);
if (!canManage) {
throw new Error("You don't have permission to manage emojis");
}
const emoji = await ctx.db.get(args.emojiId);
if (!emoji) throw new Error("Emoji not found");
await ctx.storage.delete(emoji.storageId);
await ctx.db.delete(args.emojiId);
return null;
},
});

View File

@@ -132,4 +132,11 @@ export default defineSchema({
afkTimeout: v.number(), // seconds (default 300 = 5 min)
iconStorageId: v.optional(v.id("_storage")),
}),
customEmojis: defineTable({
name: v.string(),
storageId: v.id("_storage"),
uploadedBy: v.id("userProfiles"),
createdAt: v.number(),
}).index("by_name", ["name"]),
});

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/shared",
"private": true,
"version": "1.0.20",
"version": "1.0.21",
"type": "module",
"main": "src/App.jsx",
"dependencies": {

View File

@@ -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();

View File

@@ -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,12 +110,9 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
);
}
return (
<div className="emoji-grid" style={{ height: '100%' }}>
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
<div key={category} style={{ marginBottom: '8px' }}>
const CategoryHeader = ({ name, collapsed }) => (
<div
onClick={() => toggleCategory(category)}
onClick={() => toggleCategory(name)}
style={{
display: 'flex',
alignItems: 'center',
@@ -138,7 +136,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
strokeLinejoin="round"
style={{
marginRight: '8px',
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}
>
@@ -151,9 +149,28 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
fontWeight: 700,
margin: 0
}}>
{category}
{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' }}>
<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>

View File

@@ -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 `![${match}](${custom.src})`;
const emoji = AllEmojis.find(e => e.name === name);
return emoji ? `![${match}](${emoji.src})` : 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
);
});

View File

@@ -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>
);
};