/** * MobileEmojisTab — full-screen Fluxer-style mobile variant of * EmojisTab. Layout: * * ┌────────── Upload Emoji ──────────┐ ← brand-primary CTA * Add up to 50 custom emoji… helper text * UPLOAD REQUIREMENTS section label * ┌─────── card ──────────────┐ * │ • File type: PNG, GIF, … │ * │ • Recommended size: 256KB │ * │ • Recommended dimensions… │ * │ • Naming: 2+ chars, … │ * └────────────────────────────┘ * EMOJI — N SLOTS AVAILABLE section label * ┌────────── card ────────────┐ * │ [img] :hazmat: ⋯ │ * │ [img] :blank: ⋯ │ * │ ... │ * └────────────────────────────┘ * * The uploader column from the Fluxer reference is intentionally * dropped — MSC2545's `images` dict only stores `{shortcode → mxc}` * with no record of who added each entry, so there's nothing to * surface. Tapping the trash icon prompts a confirm and removes. * * The whole tab is gated behind `EmojiPackManager.canManageEmojis` * (native power-level check) — same gate as desktop. Non-admins * see a friendly fallback explaining how to get access. */ import { observer } from 'mobx-react-lite'; import { useMemo, useRef, useState } from 'react'; import { Trash, UploadSimple } from '@phosphor-icons/react'; import { EmojiPackManager, MAX_EMOJIS_PER_PACK, type CustomEmoji, } from '@brycord/matrix-client'; import EmojiPackStore from '@app/stores/EmojiPackStore'; import { CustomEmojiImage } from '../channel/CustomEmojiImage'; import styles from './MobileEmojisTab.module.css'; interface MobileEmojisTabProps { serverId: string; } const SHORTCODE_REGEX = /^[a-z0-9_]{2,30}$/; const MAX_FILE_BYTES = 256 * 1024; export const MobileEmojisTab = observer(function MobileEmojisTab({ serverId, }: MobileEmojisTabProps) { const [pendingFile, setPendingFile] = useState(null); const [pendingPreviewUrl, setPendingPreviewUrl] = useState( null, ); const [shortcode, setShortcode] = useState(''); const [uploading, setUploading] = useState(false); const [deletingShortcode, setDeletingShortcode] = useState( null, ); const [status, setStatus] = useState< { type: 'success' | 'error'; message: string } | null >(null); const fileInputRef = useRef(null); const canManage = useMemo(() => { try { return EmojiPackManager.getInstance().canManageEmojis(serverId); } catch { return false; } }, [serverId]); const pack = EmojiPackStore.getPack(serverId); const emojis: CustomEmoji[] = pack?.emojis ?? []; const slotsRemaining = MAX_EMOJIS_PER_PACK - emojis.length; const isFull = slotsRemaining <= 0; if (!canManage) { return (

Emojis

You need permission to manage emojis in this server. Ask an admin to grant you a role with a higher power level.

); } const handleFilePick = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; // Reset input so the same file can be picked again after clearing. if (fileInputRef.current) fileInputRef.current.value = ''; if (!file) return; setStatus(null); if (!file.type.startsWith('image/')) { setStatus({ type: 'error', message: 'Emoji must be a PNG, GIF, WebP, or JPEG image.', }); return; } if (file.size > MAX_FILE_BYTES) { setStatus({ type: 'error', message: 'File is too large. Maximum is 256 KB.', }); return; } if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl); setPendingFile(file); setPendingPreviewUrl(URL.createObjectURL(file)); // Auto-suggest a shortcode from the filename, same heuristic // the desktop tab uses. if (!shortcode) { const base = file.name.replace(/\.[^.]+$/, '').toLowerCase(); const cleaned = base.replace(/[^a-z0-9_]/g, '_').slice(0, 30); if (cleaned.length >= 2) setShortcode(cleaned); } }; const handleUpload = async () => { if (!pendingFile || uploading) return; const trimmed = shortcode.trim(); if (!SHORTCODE_REGEX.test(trimmed)) { setStatus({ type: 'error', message: 'Shortcode must be 2–30 chars (lowercase letters, digits, underscores).', }); return; } if (emojis.some((e) => e.shortcode === trimmed)) { setStatus({ type: 'error', message: `An emoji named "${trimmed}" already exists.`, }); return; } setUploading(true); setStatus(null); try { await EmojiPackManager.getInstance().addEmoji(serverId, trimmed, pendingFile); if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl); setPendingFile(null); setPendingPreviewUrl(null); setShortcode(''); setStatus({ type: 'success', message: `Added :${trimmed}:` }); } catch (err: any) { setStatus({ type: 'error', message: err?.message || 'Failed to upload emoji.', }); } finally { setUploading(false); } }; const handleClearPending = () => { if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl); setPendingFile(null); setPendingPreviewUrl(null); setShortcode(''); }; const handleDelete = async (emoji: CustomEmoji) => { if (deletingShortcode) return; if (!window.confirm(`Remove :${emoji.shortcode}: from this server?`)) return; setDeletingShortcode(emoji.shortcode); setStatus(null); try { await EmojiPackManager.getInstance().removeEmoji(serverId, emoji.shortcode); setStatus({ type: 'success', message: `Removed :${emoji.shortcode}:` }); } catch (err: any) { setStatus({ type: 'error', message: err?.message || 'Failed to remove emoji.', }); } finally { setDeletingShortcode(null); } }; return (

Add up to {MAX_EMOJIS_PER_PACK} custom emoji that anyone can use in this server. Animated GIF and WebP emoji play automatically.

Upload Requirements
  • File type: PNG, GIF, WebP, JPEG
  • Maximum file size: 256 KB
  • Recommended dimensions: 128×128
  • Naming: Emoji names must be at least 2 characters long and can only contain lowercase letters, digits, and underscores.
{/* Inline upload form — only visible after a file has been picked. Lets the user name and confirm the upload, or cancel out without leaving the tab. */} {pendingFile && (
{pendingPreviewUrl && ( pending emoji )}
setShortcode( e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''), ) } maxLength={30} />
)} {status && (
{status.message}
)}
Emoji — {Math.max(0, slotsRemaining)} Slots Available
{emojis.length === 0 ? (
No custom emojis yet. Tap "Upload Emoji" to add one.
) : (
{emojis.map((emoji) => (
:{emoji.shortcode}:
))}
)}
); });