diff --git a/frontend/src/components/CustomEmojiManageModal.tsx b/frontend/src/components/CustomEmojiManageModal.tsx new file mode 100644 index 0000000..dd60200 --- /dev/null +++ b/frontend/src/components/CustomEmojiManageModal.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react' +import { deleteCustomEmoji } from '../api/customEmoji' +import { useAuth } from '../context/AuthContext' +import { useCustomEmoji } from '../context/CustomEmojiContext' +import type { CustomEmoji } from '../types' +import { CustomEmojiUploadModal } from './CustomEmojiUploadModal' +import { EmojiGlyph } from './MessageContent' +import './Modal.css' + +interface CustomEmojiManageModalProps { + onClose: () => void +} + +// Moved out of the reaction/composer emoji picker -- that grid packs items +// 9-to-a-row with a delete "x" overlapping the glyph itself, which on a +// touch screen is far too easy to hit by accident while just trying to +// react. A dedicated list with a normal-sized "Delete" button (plus the +// same confirm() every other destructive action in this app uses) needs a +// deliberate tap to actually delete something. +export function CustomEmojiManageModal({ onClose }: CustomEmojiManageModalProps) { + const { user } = useAuth() + const { list, refresh } = useCustomEmoji() + const [uploadOpen, setUploadOpen] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + async function handleDelete(emoji: CustomEmoji) { + if (!confirm(`Delete :${emoji.shortcode}:? This can't be undone.`)) return + setDeletingId(emoji.id) + try { + await deleteCustomEmoji(emoji.id) + await refresh() + } finally { + setDeletingId(null) + } + } + + return ( +
+
e.stopPropagation()}> +
+

Custom emoji

+ +
+ +
Site emoji
+ {list.length === 0 ? ( +

No custom emoji yet.

+ ) : ( + list.map((emoji) => { + const canDelete = user?.id === emoji.uploaded_by || user?.is_site_admin + return ( +
+
+
+ :{emoji.shortcode}: +
+
+ Added {new Date(emoji.created_at).toLocaleDateString()} +
+
+ {canDelete && ( + + )} +
+ ) + }) + )} + +
+ + +
+
+ + {uploadOpen && ( + setUploadOpen(false)} + onUploaded={() => { + refresh() + setUploadOpen(false) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/components/EmojiPicker.css b/frontend/src/components/EmojiPicker.css index b872a31..f60f9fe 100644 --- a/frontend/src/components/EmojiPicker.css +++ b/frontend/src/components/EmojiPicker.css @@ -69,26 +69,6 @@ padding: 4px 4px 2px; } -.emoji-picker-category-label-row { - display: flex; - align-items: center; - justify-content: space-between; -} - -.emoji-picker-add-custom { - background: transparent; - border: none; - color: var(--ds-accent); - font-size: 0.7rem; - font-weight: 600; - cursor: pointer; - padding: 2px 4px; -} - -.emoji-picker-add-custom:hover { - text-decoration: underline; -} - .emoji-picker-grid { display: grid; grid-template-columns: repeat(9, 1fr); @@ -109,32 +89,6 @@ background: var(--ds-surface-2); } -.emoji-picker-item-custom { - position: relative; -} - -.emoji-picker-item-remove { - position: absolute; - top: -2px; - right: -2px; - display: flex; - align-items: center; - justify-content: center; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--ds-danger); - color: white; - font-size: 0.65rem; - line-height: 1; - opacity: 0; - cursor: pointer; -} - -.emoji-picker-item-custom:hover .emoji-picker-item-remove { - opacity: 1; -} - /* The picker is positioned absolutely relative to its trigger button, which can sit close enough to a narrow viewport's edge that the full 320px width runs off-screen (e.g. the composer's emoji trigger, near the left diff --git a/frontend/src/components/EmojiPicker.tsx b/frontend/src/components/EmojiPicker.tsx index 6e503a4..cb426b7 100644 --- a/frontend/src/components/EmojiPicker.tsx +++ b/frontend/src/components/EmojiPicker.tsx @@ -1,12 +1,9 @@ -import { useMemo, useState, type MouseEvent } from 'react' -import { deleteCustomEmoji } from '../api/customEmoji' -import { useAuth } from '../context/AuthContext' +import { useMemo, useState } from 'react' import { useCustomEmoji } from '../context/CustomEmojiContext' import { useEscapeKey } from '../hooks/useEscapeKey' import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji' import { EMOJI_NAMES } from '../lib/emojiNames' import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji' -import { CustomEmojiUploadModal } from './CustomEmojiUploadModal' import { EmojiGlyph } from './MessageContent' import './EmojiPicker.css' @@ -55,11 +52,8 @@ function searchEmoji(query: string, customShortcodes: string[]): string[] { export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) { useEscapeKey(onClose) - const { user } = useAuth() - const { list: customEmoji, refresh: refreshCustomEmoji } = useCustomEmoji() + const { list: customEmoji } = useCustomEmoji() const [query, setQuery] = useState('') - const [uploadOpen, setUploadOpen] = useState(false) - const [deletingId, setDeletingId] = useState(null) const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji]) const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes]) const searching = query.trim().length > 0 @@ -74,18 +68,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef onPick(emoji) } - async function handleDeleteCustomEmoji(e: MouseEvent, emojiId: string) { - // Delete, not pick -- must never bubble to the button's own onClick. - e.stopPropagation() - setDeletingId(emojiId) - try { - await deleteCustomEmoji(emojiId) - await refreshCustomEmoji() - } finally { - setDeletingId(null) - } - } - return ( <>
@@ -122,48 +104,25 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef ) ) : ( <> -
-
+ {customEmoji.length > 0 && ( +
Custom
- -
- {customEmoji.length > 0 && (
- {customEmoji.map((e) => { - const canDelete = user?.id === e.uploaded_by || user?.is_site_admin - return ( - - ) - })} + {customEmoji.map((e) => ( + + ))}
- )} -
+
+ )} {recent.length > 0 && (
Recently used
@@ -205,15 +164,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef )}
- {uploadOpen && ( - setUploadOpen(false)} - onUploaded={() => { - refreshCustomEmoji() - setUploadOpen(false) - }} - /> - )} ) } diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 22f523c..ecfdadd 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -13,6 +13,7 @@ import { } from '../lib/desktopBridge' import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push' import { AboutModal } from './AboutModal' +import { CustomEmojiManageModal } from './CustomEmojiManageModal' import { ProfileModal } from './ProfileModal' import { UserAvatar } from './UserAvatar' import './TopBar.css' @@ -28,6 +29,7 @@ export function TopBar() { const navigate = useNavigate() const [menuOpen, setMenuOpen] = useState(false) const [profileModalOpen, setProfileModalOpen] = useState(false) + const [customEmojiModalOpen, setCustomEmojiModalOpen] = useState(false) const [aboutModalOpen, setAboutModalOpen] = useState(false) const [pushSubscribed, setPushSubscribed] = useState(false) const [pushBusy, setPushBusy] = useState(false) @@ -124,6 +126,16 @@ export function TopBar() { > Profile settings +