Files
DiscordClone/packages/shared/src/components/settings/MobileEmojisTab.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- 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.
2026-04-14 09:02:14 -05:00

331 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 230 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>
);
});