From b63c7a71e1fbf8b17f320d8e909110cd7d9cbe77 Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:33:37 -0600 Subject: [PATCH] Add custom emojis --- TODO.md | 11 +- apps/electron/package.json | 2 +- convex/_generated/api.d.ts | 2 + convex/customEmojis.ts | 98 ++++ convex/schema.ts | 7 + packages/shared/package.json | 2 +- packages/shared/src/components/ChatArea.jsx | 26 +- packages/shared/src/components/GifPicker.jsx | 112 +++-- .../shared/src/components/MessageItem.jsx | 23 +- .../src/components/ServerSettingsModal.jsx | 453 +++++++++++++++++- 10 files changed, 663 insertions(+), 73 deletions(-) create mode 100644 convex/customEmojis.ts diff --git a/TODO.md b/TODO.md index c70761d..beda5b2 100644 --- a/TODO.md +++ b/TODO.md @@ -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. \ No newline at end of file +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 \ No newline at end of file diff --git a/apps/electron/package.json b/apps/electron/package.json index 86c4652..370645c 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -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", diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 1cd3e0a..194485c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/customEmojis.ts b/convex/customEmojis.ts new file mode 100644 index 0000000..4fe2057 --- /dev/null +++ b/convex/customEmojis.ts @@ -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)?.["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)?.["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; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 270e36f..ad0d5c4 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]), }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 091e402..96c928a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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": { diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx index b80954f..d9db396 100644 --- a/packages/shared/src/components/ChatArea.jsx +++ b/packages/shared/src/components/ChatArea.jsx @@ -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 {contextMenu && setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />} + {reactionPickerMsgId && ( +
setReactionPickerMsgId(null)}> +
e.stopPropagation()}> + { + if (typeof data !== 'string' && data.name) { + addReaction({ messageId: reactionPickerMsgId, userId: currentUserId, emoji: data.name }); + } + setReactionPickerMsgId(null); + }} + onClose={() => setReactionPickerMsgId(null)} + /> +
+
+ )} {inputContextMenu && setInputContextMenu(null)} onPaste={async () => { try { if (inputDivRef.current) inputDivRef.current.focus(); diff --git a/packages/shared/src/components/GifPicker.jsx b/packages/shared/src/components/GifPicker.jsx index 8f30a8f..455868c 100644 --- a/packages/shared/src/components/GifPicker.jsx +++ b/packages/shared/src/components/GifPicker.jsx @@ -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 (
@@ -109,51 +110,67 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory }) ); } + const CategoryHeader = ({ name, collapsed }) => ( +
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'} + > + + + +

+ {name} +

+
+ ); + return (
+ {customEmojis.length > 0 && ( +
+ + {!collapsedCategories['Custom'] && ( +
+ {customEmojis.map((emoji) => ( + + ))} +
+ )} +
+ )} {Object.entries(CategorizedEmojis).map(([category, emojis]) => (
-
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'} - > - - - -

- {category} -

-
+ {!collapsedCategories[category] && (
{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' ? ( ) : ( - + )}
diff --git a/packages/shared/src/components/MessageItem.jsx b/packages/shared/src/components/MessageItem.jsx index 26ab9bd..7bfff44 100644 --- a/packages/shared/src/components/MessageItem.jsx +++ b/packages/shared/src/components/MessageItem.jsx @@ -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 }) =>
, img: ({ node, alt, src, ...props }) => { if (alt && alt.startsWith(':') && alt.endsWith(':')) { - return {alt}; + return {alt}; } return {alt}; }, @@ -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 && ( url} components={markdownComponents}> - {formatEmojis(formatMentions(msg.content, roles))} + {formatEmojis(formatMentions(msg.content, roles), customEmojis)} )} {isDirectVideo && } @@ -250,7 +258,7 @@ const MessageItem = React.memo(({
{Object.entries(msg.reactions).map(([emojiName, data]) => (
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' }}> - + {data.count}
))} @@ -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 ); }); diff --git a/packages/shared/src/components/ServerSettingsModal.jsx b/packages/shared/src/components/ServerSettingsModal.jsx index 96f1c24..5820297 100644 --- a/packages/shared/src/components/ServerSettingsModal.jsx +++ b/packages/shared/src/components/ServerSettingsModal.jsx @@ -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 }) => {
Server Settings
- {['Overview', 'Roles', 'Members'].map(tab => ( + {['Overview', 'Emoji', 'Roles', 'Members'].map(tab => (
setActiveTab(tab)} @@ -367,8 +509,94 @@ const ServerSettingsModal = ({ onClose }) => {
); + const renderEmojiTab = () => ( +
+
+

+ Add custom emoji that anyone can use in this server. +

+ {myPermissions.manage_channels && ( + <> + + + + )} +
+ + {/* Emoji table */} +
+ {/* Table header */} +
+ Image + Name + Uploaded By + +
+ + {customEmojis.length === 0 ? ( +
+ No custom emojis yet +
+ ) : ( + customEmojis.map(emoji => ( +
+ {emoji.name} + :{emoji.name}: + {emoji.uploadedByUsername} +
+ {myPermissions.manage_channels && ( + + )} +
+
+ )) + )} +
+
+ ); + 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 && ( +
+
e.stopPropagation()} + style={{ + backgroundColor: 'var(--bg-secondary)', borderRadius: 8, + width: 580, maxWidth: '90vw', overflow: 'hidden', + }} + > + {/* Modal header */} +
+

Add Emoji

+ +
+ + {/* Modal body */} +
+ {/* Left: Cropper + toolbar + zoom */} +
+
+ +
+ + {/* Toolbar: rotate + flip */} +
+ + + + +
+ + {/* Zoom slider */} +
+ + + + setEmojiZoom(Number(e.target.value))} + className="avatar-crop-slider" + /> + + + +
+
+ + {/* Right: Reaction preview + Name + Finish */} +
+ {/* Reaction pill preview */} +
+ + Preview + +
+ + 1 +
+
+ + {/* Emoji name input */} +
+ +
+ { 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 && ( + + )} +
+ {emojiError && ( +
{emojiError}
+ )} +
+ +
+ + {/* Finish button */} + +
+
+
+
+ )}
); };