All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
331 lines
9.7 KiB
TypeScript
331 lines
9.7 KiB
TypeScript
/**
|
||
* 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<File | null>(null);
|
||
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [shortcode, setShortcode] = useState('');
|
||
const [uploading, setUploading] = useState(false);
|
||
const [deletingShortcode, setDeletingShortcode] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [status, setStatus] = useState<
|
||
{ type: 'success' | 'error'; message: string } | null
|
||
>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||
<div className={styles.gate}>
|
||
<h2 className={styles.gateTitle}>Emojis</h2>
|
||
<p className={styles.gateBody}>
|
||
You need permission to manage emojis in this server. Ask an
|
||
admin to grant you a role with a higher power level.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handleFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
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 (
|
||
<div className={styles.root}>
|
||
<button
|
||
type="button"
|
||
className={styles.uploadButton}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isFull || uploading}
|
||
>
|
||
<UploadSimple size={18} weight="bold" />
|
||
Upload Emoji
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/gif,image/webp,image/jpeg"
|
||
onChange={handleFilePick}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
|
||
<p className={styles.helperText}>
|
||
Add up to {MAX_EMOJIS_PER_PACK} custom emoji that anyone can use
|
||
in this server. Animated GIF and WebP emoji play automatically.
|
||
</p>
|
||
|
||
<div className={styles.sectionLabel}>Upload Requirements</div>
|
||
<div className={styles.requirementsCard}>
|
||
<ul className={styles.requirementsList}>
|
||
<li>
|
||
<strong>File type:</strong> PNG, GIF, WebP, JPEG
|
||
</li>
|
||
<li>
|
||
<strong>Maximum file size:</strong> 256 KB
|
||
</li>
|
||
<li>
|
||
<strong>Recommended dimensions:</strong> 128×128
|
||
</li>
|
||
<li>
|
||
<strong>Naming:</strong> Emoji names must be at least 2
|
||
characters long and can only contain lowercase letters,
|
||
digits, and underscores.
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{/* 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 && (
|
||
<div className={styles.uploadForm}>
|
||
<div className={styles.uploadFormRow}>
|
||
<div className={styles.uploadPreview}>
|
||
{pendingPreviewUrl && (
|
||
<img
|
||
src={pendingPreviewUrl}
|
||
alt="pending emoji"
|
||
className={styles.uploadPreviewImage}
|
||
draggable={false}
|
||
/>
|
||
)}
|
||
</div>
|
||
<input
|
||
type="text"
|
||
className={styles.shortcodeInput}
|
||
placeholder="shortcode"
|
||
value={shortcode}
|
||
onChange={(e) =>
|
||
setShortcode(
|
||
e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''),
|
||
)
|
||
}
|
||
maxLength={30}
|
||
/>
|
||
</div>
|
||
<div className={styles.uploadFormActions}>
|
||
<button
|
||
type="button"
|
||
className={`${styles.formButton} ${styles.formButtonSecondary}`}
|
||
onClick={handleClearPending}
|
||
disabled={uploading}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`${styles.formButton} ${styles.formButtonPrimary}`}
|
||
onClick={handleUpload}
|
||
disabled={uploading || !SHORTCODE_REGEX.test(shortcode)}
|
||
>
|
||
{uploading ? 'Uploading…' : 'Add Emoji'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{status && (
|
||
<div
|
||
className={`${styles.status} ${
|
||
status.type === 'error' ? styles.statusError : styles.statusSuccess
|
||
}`}
|
||
>
|
||
{status.message}
|
||
</div>
|
||
)}
|
||
|
||
<div className={styles.sectionLabel}>
|
||
Emoji — {Math.max(0, slotsRemaining)} Slots Available
|
||
</div>
|
||
|
||
{emojis.length === 0 ? (
|
||
<div className={styles.empty}>
|
||
No custom emojis yet. Tap "Upload Emoji" to add one.
|
||
</div>
|
||
) : (
|
||
<div className={styles.emojiCard}>
|
||
{emojis.map((emoji) => (
|
||
<div key={emoji.mxcUrl} className={styles.emojiRow}>
|
||
<div className={styles.emojiThumb}>
|
||
<CustomEmojiImage
|
||
mxc={emoji.mxcUrl}
|
||
alt={`:${emoji.shortcode}:`}
|
||
title={`:${emoji.shortcode}:`}
|
||
className={styles.emojiThumbImage}
|
||
draggable={false}
|
||
/>
|
||
</div>
|
||
<span className={styles.emojiName}>:{emoji.shortcode}:</span>
|
||
<button
|
||
type="button"
|
||
className={styles.deleteButton}
|
||
onClick={() => handleDelete(emoji)}
|
||
disabled={deletingShortcode === emoji.shortcode}
|
||
aria-label={`Remove :${emoji.shortcode}:`}
|
||
>
|
||
<Trash size={18} weight="fill" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|