feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
330
packages/shared/src/components/settings/MobileEmojisTab.tsx
Normal file
330
packages/shared/src/components/settings/MobileEmojisTab.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user