Move custom emoji management out of the emoji picker

The picker's delete "x" overlaps the glyph in a tightly packed grid,
which is too easy to hit by accident on a touch screen. Adding and
deleting custom emoji now live in their own modal under the account
menu, with delete gated behind the same confirm() every other
destructive action in the app uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 19:02:21 -06:00
co-authored by Claude Sonnet 5
parent 03cc16f236
commit b4a104f8c6
4 changed files with 130 additions and 114 deletions
@@ -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<string | null>(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 (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Custom emoji</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
<div className="modal-field-label">Site emoji</div>
{list.length === 0 ? (
<p className="modal-empty">No custom emoji yet.</p>
) : (
list.map((emoji) => {
const canDelete = user?.id === emoji.uploaded_by || user?.is_site_admin
return (
<div key={emoji.id} className="modal-list-row">
<div className="modal-list-row-body">
<div className="modal-list-row-title">
<EmojiGlyph value={`:${emoji.shortcode}:`} /> :{emoji.shortcode}:
</div>
<div className="modal-list-row-sub">
Added {new Date(emoji.created_at).toLocaleDateString()}
</div>
</div>
{canDelete && (
<button
type="button"
className="modal-list-row-action"
disabled={deletingId === emoji.id}
onClick={() => handleDelete(emoji)}
>
{deletingId === emoji.id ? 'Deleting…' : 'Delete'}
</button>
)}
</div>
)
})
)}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>
Close
</button>
<button type="button" className="btn-primary" onClick={() => setUploadOpen(true)}>
Add emoji
</button>
</div>
</div>
{uploadOpen && (
<CustomEmojiUploadModal
onClose={() => setUploadOpen(false)}
onUploaded={() => {
refresh()
setUploadOpen(false)
}}
/>
)}
</div>
)
}
-46
View File
@@ -69,26 +69,6 @@
padding: 4px 4px 2px; 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 { .emoji-picker-grid {
display: grid; display: grid;
grid-template-columns: repeat(9, 1fr); grid-template-columns: repeat(9, 1fr);
@@ -109,32 +89,6 @@
background: var(--ds-surface-2); 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 /* 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 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 width runs off-screen (e.g. the composer's emoji trigger, near the left
+18 -68
View File
@@ -1,12 +1,9 @@
import { useMemo, useState, type MouseEvent } from 'react' import { useMemo, useState } from 'react'
import { deleteCustomEmoji } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext' import { useCustomEmoji } from '../context/CustomEmojiContext'
import { useEscapeKey } from '../hooks/useEscapeKey' import { useEscapeKey } from '../hooks/useEscapeKey'
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji' import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
import { EMOJI_NAMES } from '../lib/emojiNames' import { EMOJI_NAMES } from '../lib/emojiNames'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji' import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import { CustomEmojiUploadModal } from './CustomEmojiUploadModal'
import { EmojiGlyph } from './MessageContent' import { EmojiGlyph } from './MessageContent'
import './EmojiPicker.css' import './EmojiPicker.css'
@@ -55,11 +52,8 @@ function searchEmoji(query: string, customShortcodes: string[]): string[] {
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) { export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
useEscapeKey(onClose) useEscapeKey(onClose)
const { user } = useAuth() const { list: customEmoji } = useCustomEmoji()
const { list: customEmoji, refresh: refreshCustomEmoji } = useCustomEmoji()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [uploadOpen, setUploadOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji]) const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji])
const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes]) const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
const searching = query.trim().length > 0 const searching = query.trim().length > 0
@@ -74,18 +68,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
onPick(emoji) 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 ( return (
<> <>
<div className="emoji-picker-scrim" onClick={onClose} /> <div className="emoji-picker-scrim" onClick={onClose} />
@@ -122,48 +104,25 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
) )
) : ( ) : (
<> <>
<div className="emoji-picker-category"> {customEmoji.length > 0 && (
<div className="emoji-picker-category-label-row"> <div className="emoji-picker-category">
<div className="emoji-picker-category-label">Custom</div> <div className="emoji-picker-category-label">Custom</div>
<button
type="button"
className="emoji-picker-add-custom"
onClick={() => setUploadOpen(true)}
>
+ Add
</button>
</div>
{customEmoji.length > 0 && (
<div className="emoji-picker-grid"> <div className="emoji-picker-grid">
{customEmoji.map((e) => { {customEmoji.map((e) => (
const canDelete = user?.id === e.uploaded_by || user?.is_site_admin <button
return ( key={e.id}
<button type="button"
key={e.id} role="menuitem"
type="button" className="emoji-picker-item"
role="menuitem" title={`:${e.shortcode}:`}
className="emoji-picker-item emoji-picker-item-custom" onClick={() => pick(`:${e.shortcode}:`)}
title={`:${e.shortcode}:`} >
onClick={() => pick(`:${e.shortcode}:`)} <EmojiGlyph value={`:${e.shortcode}:`} />
> </button>
<EmojiGlyph value={`:${e.shortcode}:`} /> ))}
{canDelete && (
<span
role="button"
aria-label={`Remove :${e.shortcode}:`}
className="emoji-picker-item-remove"
onClick={(ev) => handleDeleteCustomEmoji(ev, e.id)}
style={deletingId === e.id ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
>
×
</span>
)}
</button>
)
})}
</div> </div>
)} </div>
</div> )}
{recent.length > 0 && ( {recent.length > 0 && (
<div className="emoji-picker-category"> <div className="emoji-picker-category">
<div className="emoji-picker-category-label">Recently used</div> <div className="emoji-picker-category-label">Recently used</div>
@@ -205,15 +164,6 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
</> </>
)} )}
</div> </div>
{uploadOpen && (
<CustomEmojiUploadModal
onClose={() => setUploadOpen(false)}
onUploaded={() => {
refreshCustomEmoji()
setUploadOpen(false)
}}
/>
)}
</> </>
) )
} }
+13
View File
@@ -13,6 +13,7 @@ import {
} from '../lib/desktopBridge' } from '../lib/desktopBridge'
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push' import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
import { AboutModal } from './AboutModal' import { AboutModal } from './AboutModal'
import { CustomEmojiManageModal } from './CustomEmojiManageModal'
import { ProfileModal } from './ProfileModal' import { ProfileModal } from './ProfileModal'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import './TopBar.css' import './TopBar.css'
@@ -28,6 +29,7 @@ export function TopBar() {
const navigate = useNavigate() const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false) const [profileModalOpen, setProfileModalOpen] = useState(false)
const [customEmojiModalOpen, setCustomEmojiModalOpen] = useState(false)
const [aboutModalOpen, setAboutModalOpen] = useState(false) const [aboutModalOpen, setAboutModalOpen] = useState(false)
const [pushSubscribed, setPushSubscribed] = useState(false) const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false) const [pushBusy, setPushBusy] = useState(false)
@@ -124,6 +126,16 @@ export function TopBar() {
> >
Profile settings Profile settings
</button> </button>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setCustomEmojiModalOpen(true)
}}
>
Custom emoji
</button>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
@@ -190,6 +202,7 @@ export function TopBar() {
)} )}
</div> </div>
{profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />} {profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />}
{customEmojiModalOpen && <CustomEmojiManageModal onClose={() => setCustomEmojiModalOpen(false)} />}
{aboutModalOpen && <AboutModal onClose={() => setAboutModalOpen(false)} />} {aboutModalOpen && <AboutModal onClose={() => setAboutModalOpen(false)} />}
</header> </header>
) )