/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import * as GuildEmojiActionCreators from '@app/actions/GuildEmojiActionCreators'; import * as ModalActionCreators from '@app/actions/ModalActionCreators'; import {modal} from '@app/actions/ModalActionCreators'; import * as ToastActionCreators from '@app/actions/ToastActionCreators'; import styles from '@app/components/emojis/EmojiListItem.module.css'; import {Input} from '@app/components/form/Input'; import {ConfirmModal} from '@app/components/modals/ConfirmModal'; import {Button} from '@app/components/uikit/button/Button'; import {Checkbox} from '@app/components/uikit/checkbox/Checkbox'; import FocusRing from '@app/components/uikit/focus_ring/FocusRing'; import {InlineEdit} from '@app/components/uikit/InlineEdit'; import {Popout} from '@app/components/uikit/popout/Popout'; import {Tooltip} from '@app/components/uikit/tooltip/Tooltip'; import {useStickerAnimation} from '@app/hooks/useStickerAnimation'; import {Logger} from '@app/lib/Logger'; import GuildStore from '@app/stores/GuildStore'; import * as AvatarUtils from '@app/utils/AvatarUtils'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import type {GuildEmojiWithUser} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; import {Trans, useLingui} from '@lingui/react/macro'; import {XIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useEffect, useRef, useState} from 'react'; const logger = new Logger('EmojiListItem'); interface EmojiRenamePopoutContentProps { initialName: string; onSave: (newName: string) => Promise; onClose: () => void; } const EmojiRenamePopoutContent: React.FC = ({initialName, onSave, onClose}) => { const {t} = useLingui(); const [draft, setDraft] = useState(initialName); const [isSaving, setIsSaving] = useState(false); const inputRef = useRef(null); useEffect(() => { requestAnimationFrame(() => inputRef.current?.focus()); }, []); const sanitizedDraft = draft.replace(/[^a-zA-Z0-9_]/g, ''); const isDraftValid = sanitizedDraft.length >= 2 && sanitizedDraft.length <= 32; const handleSubmit = async () => { if (!isDraftValid || isSaving) return; setIsSaving(true); try { await onSave(sanitizedDraft); onClose(); } finally { setIsSaving(false); } }; const handleInputChange: React.ChangeEventHandler = (e) => { const {value, selectionStart, selectionEnd} = e.target; const next = value.replace(/[^a-zA-Z0-9_]/g, ''); const removed = value.length - next.length; setDraft(next); if (inputRef.current && selectionStart !== null && selectionEnd !== null) { const newStart = Math.max(0, selectionStart - removed); const newEnd = Math.max(0, selectionEnd - removed); requestAnimationFrame(() => inputRef.current?.setSelectionRange(newStart, newEnd)); } }; return (
{ e.preventDefault(); void handleSubmit(); }} >
Rename Emoji 2-32 characters, letters, numbers, underscores.
); }; export const EmojiListHeader: React.FC = observer(() => (
Emoji
Name
Uploaded By
)); export const EmojiListItem: React.FC<{ guildId: string; emoji: GuildEmojiWithUser; layout: 'list' | 'grid'; canModify: boolean; onRename: (emojiId: string, newName: string) => void; onRemove: (emojiId: string) => void; }> = observer(({guildId, emoji, layout, canModify, onRename, onRemove}) => { const {t} = useLingui(); const avatarUrl = emoji.user ? AvatarUtils.getUserAvatarURL(emoji.user, false) : null; const gridNameButtonRef = useRef(null); const handleSave = async (newName: string) => { const sanitizedName = newName.replace(/[^a-zA-Z0-9_]/g, ''); if (sanitizedName.length < 2) { ToastActionCreators.error(t`Emoji name must be at least 2 characters long`); throw new Error('Name too short'); } if (sanitizedName.length > 32) { ToastActionCreators.error(t`Emoji name must be at most 32 characters long`); throw new Error('Name too long'); } if (sanitizedName === emoji.name) return; const prevName = emoji.name; onRename(emoji.id, sanitizedName); try { await GuildEmojiActionCreators.update(guildId, emoji.id, {name: sanitizedName}); } catch (err) { onRename(emoji.id, prevName); logger.error('Failed to update emoji name:', err); ToastActionCreators.error(t`Failed to update emoji name. Reverted to the previous name.`); throw err; } }; const guild = GuildStore.getGuild(guildId); const canExpressionPurge = guild?.features.has(GuildFeatures.EXPRESSION_PURGE_ALLOWED) ?? false; const handleDelete = () => { ModalActionCreators.push( modal(() => ( {t`Purge this emoji from storage and CDN`} : undefined } onPrimary={async (checkboxChecked = false) => { await GuildEmojiActionCreators.remove(guildId, emoji.id, checkboxChecked && canExpressionPurge); onRemove(emoji.id); }} /> )), ); }; const {shouldAnimate} = useStickerAnimation(); const emojiUrl = AvatarUtils.getEmojiURL({id: emoji.id, animated: shouldAnimate}); if (layout === 'grid') { return (
{emoji.name} {emoji.user && avatarUrl && ( )}
{canModify ? ( ( )} > ) : ( :{emoji.name}: )}
{canModify && ( )}
); } return (
{emoji.name}
{canModify ? ( ) : ( :{emoji.name}: )}
{emoji.user && avatarUrl ? ( <> {emoji.user.username} ) : ( Unknown )}
{canModify && ( )}
); });