c77c098843
Extend useNostrPublish with an optional `prev` property on the event template. For replaceable and addressable kinds, the hook automatically manages published_at: - First publish (no prev): set published_at equal to created_at - Update (prev provided): preserve published_at from the old event - Old event lacks published_at: don't fabricate one - Caller already set published_at in tags: leave it alone Callers pass `prev` when they have the old event from fetchFreshEvent, giving the hook everything it needs without extra network requests. Updated all 11 call sites that publish replaceable or addressable events. Documents the prev convention in AGENTS.md.
562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
import { Smile, Upload, Loader2, X } from 'lucide-react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
|
import { SortableList, SortableItem } from '@/components/SortableList';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
|
|
|
/** A single emoji entry in the pack being edited. */
|
|
interface EmojiEntry {
|
|
/** Client-side key for React list rendering. */
|
|
id: string;
|
|
shortcode: string;
|
|
/** Display URL -- either a remote Blossom URL (for already-uploaded emojis) or a local blob URL (for pending files). */
|
|
url: string;
|
|
/** When set, this file still needs to be uploaded on submit. */
|
|
file?: File;
|
|
}
|
|
|
|
/** Convert a filename to a shortcode (alphanumeric, hyphens, underscores only). */
|
|
function filenameToShortcode(filename: string): string {
|
|
const dotIndex = filename.lastIndexOf('.');
|
|
const base = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
|
|
return base
|
|
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_|_$/g, '')
|
|
.toLowerCase();
|
|
}
|
|
|
|
/** Validate a shortcode against NIP-30 rules: alphanumeric, hyphens, underscores. */
|
|
function isValidShortcode(s: string): boolean {
|
|
return /^[a-zA-Z0-9_-]+$/.test(s);
|
|
}
|
|
|
|
/** Convert a title into a URL-safe slug for the identifier. */
|
|
function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\w\s-]/g, '')
|
|
.replace(/[\s_]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
let entryCounter = 0;
|
|
function nextEntryId(): string {
|
|
return `entry-${++entryCounter}-${Date.now()}`;
|
|
}
|
|
|
|
interface EmojiPackDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** When provided, the dialog opens in edit mode for an existing pack. */
|
|
editEvent?: NostrEvent;
|
|
}
|
|
|
|
export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDialogProps) {
|
|
const { user } = useCurrentUser();
|
|
const { nostr } = useNostr();
|
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
|
const { mutateAsync: uploadFile } = useUploadFile();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
const isEditMode = !!editEvent;
|
|
|
|
// Parse initial values from editEvent
|
|
const initialData = useMemo(() => {
|
|
if (!editEvent) return null;
|
|
const identifier = editEvent.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
|
const name = editEvent.tags.find(([n]) => n === 'name')?.[1] ?? '';
|
|
const emojis: EmojiEntry[] = [];
|
|
for (const tag of editEvent.tags) {
|
|
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
|
|
emojis.push({ id: nextEntryId(), shortcode: tag[1], url: tag[2] });
|
|
}
|
|
}
|
|
const about = editEvent.tags.find(([n]) => n === 'about')?.[1] ?? '';
|
|
return { identifier, name, about, emojis };
|
|
}, [editEvent]);
|
|
|
|
// Form state
|
|
const [identifier, setIdentifier] = useState(initialData?.identifier ?? '');
|
|
const [name, setName] = useState(initialData?.name ?? '');
|
|
const [about, setAbout] = useState(initialData?.about ?? '');
|
|
const [idTouched, setIdTouched] = useState(isEditMode);
|
|
const [emojis, setEmojis] = useState<EmojiEntry[]>(initialData?.emojis ?? []);
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const effectiveIdentifier = idTouched ? identifier : slugify(name);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setIdentifier(initialData?.identifier ?? '');
|
|
setName(initialData?.name ?? '');
|
|
setAbout(initialData?.about ?? '');
|
|
setIdTouched(isEditMode);
|
|
setEmojis((prev) => {
|
|
for (const e of prev) {
|
|
if (e.file) URL.revokeObjectURL(e.url);
|
|
}
|
|
return initialData?.emojis ?? [];
|
|
});
|
|
setIsDragOver(false);
|
|
setIsSubmitting(false);
|
|
}, [initialData, isEditMode]);
|
|
|
|
const handleNameChange = useCallback((value: string) => {
|
|
setName(value);
|
|
if (!idTouched) {
|
|
setIdentifier(slugify(value));
|
|
}
|
|
}, [idTouched]);
|
|
|
|
const handleIdChange = useCallback((value: string) => {
|
|
setIdTouched(true);
|
|
setIdentifier(value);
|
|
}, []);
|
|
|
|
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
|
if (!nextOpen) resetForm();
|
|
onOpenChange(nextOpen);
|
|
}, [onOpenChange, resetForm]);
|
|
|
|
/** Add image files to the emoji list as local previews (no upload yet). */
|
|
const addFiles = useCallback((files: File[]) => {
|
|
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
|
if (imageFiles.length === 0) {
|
|
toast({ title: 'No images', description: 'No image files found.', variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
const newEntries: EmojiEntry[] = imageFiles.map((file) => ({
|
|
id: nextEntryId(),
|
|
shortcode: filenameToShortcode(file.name),
|
|
url: URL.createObjectURL(file),
|
|
file,
|
|
}));
|
|
|
|
setEmojis((prev) => [...prev, ...newEntries]);
|
|
}, [toast]);
|
|
|
|
/** Handle drop zone events. */
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
|
|
const items = e.dataTransfer.items;
|
|
const files: File[] = [];
|
|
|
|
if (items) {
|
|
const entries: FileSystemEntry[] = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const entry = items[i].webkitGetAsEntry?.();
|
|
if (entry) {
|
|
entries.push(entry);
|
|
} else {
|
|
const file = items[i].getAsFile();
|
|
if (file) files.push(file);
|
|
}
|
|
}
|
|
|
|
if (entries.length > 0) {
|
|
const readAllEntries = async (dirEntries: FileSystemEntry[]): Promise<File[]> => {
|
|
const allFiles: File[] = [];
|
|
|
|
const readEntry = (entry: FileSystemEntry): Promise<void> => {
|
|
return new Promise((resolve) => {
|
|
if (entry.isFile) {
|
|
(entry as FileSystemFileEntry).file((file) => {
|
|
allFiles.push(file);
|
|
resolve();
|
|
}, () => resolve());
|
|
} else if (entry.isDirectory) {
|
|
const dirReader = (entry as FileSystemDirectoryEntry).createReader();
|
|
dirReader.readEntries(async (childEntries) => {
|
|
await Promise.all(childEntries.map(readEntry));
|
|
resolve();
|
|
}, () => resolve());
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
};
|
|
|
|
await Promise.all(dirEntries.map(readEntry));
|
|
return allFiles;
|
|
};
|
|
|
|
readAllEntries(entries).then(addFiles);
|
|
return;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < e.dataTransfer.files.length; i++) {
|
|
files.push(e.dataTransfer.files[i]);
|
|
}
|
|
addFiles(files);
|
|
}, [addFiles]);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(true);
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
}, []);
|
|
|
|
/** File input handler. */
|
|
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const fileList = e.target.files;
|
|
if (!fileList) return;
|
|
const files: File[] = [];
|
|
for (let i = 0; i < fileList.length; i++) {
|
|
files.push(fileList[i]);
|
|
}
|
|
addFiles(files);
|
|
e.target.value = '';
|
|
}, [addFiles]);
|
|
|
|
/** Update a single emoji's shortcode. */
|
|
const updateShortcode = useCallback((id: string, newShortcode: string) => {
|
|
setEmojis((prev) =>
|
|
prev.map((e) => (e.id === id ? { ...e, shortcode: newShortcode } : e)),
|
|
);
|
|
}, []);
|
|
|
|
/** Remove an emoji entry. */
|
|
const removeEmoji = useCallback((id: string) => {
|
|
setEmojis((prev) => {
|
|
const removed = prev.find((e) => e.id === id);
|
|
if (removed?.file) URL.revokeObjectURL(removed.url);
|
|
return prev.filter((e) => e.id !== id);
|
|
});
|
|
}, []);
|
|
|
|
/** Reorder emojis after a drag completes (from SortableList). */
|
|
const handleReorder = useCallback((reordered: EmojiEntry[]) => {
|
|
setEmojis(reordered);
|
|
}, []);
|
|
|
|
/** Upload all pending files, then publish the emoji pack event. */
|
|
const handlePublish = useCallback(async () => {
|
|
const resolvedId = effectiveIdentifier.trim();
|
|
if (!user || !resolvedId || emojis.length === 0) return;
|
|
|
|
// Validate all shortcodes
|
|
const invalid = emojis.find((e) => !isValidShortcode(e.shortcode));
|
|
if (invalid) {
|
|
toast({
|
|
title: 'Invalid shortcode',
|
|
description: `"${invalid.shortcode}" contains invalid characters. Use only letters, numbers, hyphens, and underscores.`,
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check for duplicate shortcodes
|
|
const seen = new Set<string>();
|
|
for (const e of emojis) {
|
|
if (seen.has(e.shortcode)) {
|
|
toast({
|
|
title: 'Duplicate shortcode',
|
|
description: `"${e.shortcode}" appears more than once. Each shortcode must be unique.`,
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
seen.add(e.shortcode);
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
// Upload all pending files in parallel
|
|
const pendingEntries = emojis.filter((e) => e.file);
|
|
const uploadResults = new Map<string, string>();
|
|
|
|
if (pendingEntries.length > 0) {
|
|
const results = await Promise.allSettled(
|
|
pendingEntries.map(async (entry) => {
|
|
const [[, url]] = await uploadFile(entry.file!);
|
|
return { id: entry.id, url };
|
|
}),
|
|
);
|
|
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') {
|
|
uploadResults.set(result.value.id, result.value.url);
|
|
}
|
|
}
|
|
|
|
const failedCount = pendingEntries.length - uploadResults.size;
|
|
if (failedCount > 0) {
|
|
toast({
|
|
title: 'Some uploads failed',
|
|
description: `${failedCount} file${failedCount !== 1 ? 's' : ''} failed to upload. Please try again.`,
|
|
variant: 'destructive',
|
|
});
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Build the final emoji list with resolved URLs
|
|
const resolvedEmojis = emojis.map((e) => ({
|
|
shortcode: e.shortcode,
|
|
url: uploadResults.get(e.id) ?? e.url,
|
|
}));
|
|
|
|
// For edit mode, fetch fresh event to preserve any tags we don't manage
|
|
let preservedTags: string[][] = [];
|
|
let prev: NostrEvent | null = null;
|
|
if (isEditMode) {
|
|
prev = await fetchFreshEvent(nostr, {
|
|
kinds: [30030],
|
|
authors: [user.pubkey],
|
|
'#d': [resolvedId],
|
|
});
|
|
if (prev) {
|
|
preservedTags = prev.tags.filter(
|
|
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
|
|
);
|
|
}
|
|
}
|
|
|
|
const tags: string[][] = [
|
|
['d', resolvedId],
|
|
...(name.trim() ? [['name', name.trim()]] : []),
|
|
...(about.trim() ? [['about', about.trim()]] : []),
|
|
...preservedTags,
|
|
...resolvedEmojis.map((e) => ['emoji', e.shortcode, e.url]),
|
|
];
|
|
|
|
await publishEvent({
|
|
kind: 30030,
|
|
content: '',
|
|
tags,
|
|
prev: prev ?? undefined,
|
|
});
|
|
|
|
// Clean up blob URLs
|
|
for (const e of emojis) {
|
|
if (e.file) URL.revokeObjectURL(e.url);
|
|
}
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
|
queryClient.invalidateQueries({ queryKey: ['custom-emojis'] });
|
|
queryClient.invalidateQueries({ queryKey: ['emoji-list'] });
|
|
|
|
toast({
|
|
title: isEditMode ? 'Emoji pack updated!' : 'Emoji pack created!',
|
|
description: `"${name.trim() || resolvedId}" with ${resolvedEmojis.length} emoji${resolvedEmojis.length !== 1 ? 's' : ''}.`,
|
|
});
|
|
|
|
handleOpenChange(false);
|
|
} catch {
|
|
toast({
|
|
title: 'Failed to publish',
|
|
description: 'Could not publish the emoji pack. Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}, [user, effectiveIdentifier, name, about, emojis, isEditMode, nostr, publishEvent, uploadFile, queryClient, toast, handleOpenChange]);
|
|
|
|
// Validation
|
|
const pendingCount = emojis.filter((e) => e.file).length;
|
|
const hasValidEmojis = emojis.length > 0 && emojis.every((e) => e.shortcode);
|
|
const canPublish = effectiveIdentifier.trim() && hasValidEmojis && !isSubmitting;
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
|
|
<DialogHeader className="px-5 pt-5 pb-3">
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Smile className="size-5 text-primary" />
|
|
{isEditMode ? 'Edit Emoji Pack' : 'Create Emoji Pack'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{isEditMode
|
|
? 'Update your custom emoji set. Drag and drop images to add more emojis.'
|
|
: 'Create a custom emoji set. Drag and drop images or a folder to populate it.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<ScrollArea className="max-h-[70vh]">
|
|
<div className="px-5 pb-5 space-y-4">
|
|
{/* Title & ID side-by-side */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="emoji-pack-name">Title</Label>
|
|
<Input
|
|
id="emoji-pack-name"
|
|
placeholder="e.g. Blobcats"
|
|
value={name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="emoji-pack-identifier">ID *</Label>
|
|
<Input
|
|
id="emoji-pack-identifier"
|
|
placeholder="e.g. blobcats"
|
|
value={idTouched ? identifier : effectiveIdentifier}
|
|
onChange={(e) => handleIdChange(e.target.value)}
|
|
disabled={isEditMode || isSubmitting}
|
|
className={`font-mono text-sm ${isEditMode ? 'text-muted-foreground' : ''}`}
|
|
/>
|
|
{isEditMode && (
|
|
<p className="text-xs text-muted-foreground">Cannot be changed.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="emoji-pack-about">Description</Label>
|
|
<Textarea
|
|
id="emoji-pack-about"
|
|
placeholder="What's in this emoji pack?"
|
|
value={about}
|
|
onChange={(e) => setAbout(e.target.value)}
|
|
disabled={isSubmitting}
|
|
className="min-h-[60px] resize-none text-sm"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
{/* Drop zone for adding emojis */}
|
|
<div className="space-y-1.5">
|
|
<Label>Emojis</Label>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => !isSubmitting && fileInputRef.current?.click()}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onKeyDown={(e) => { if (!isSubmitting && (e.key === 'Enter' || e.key === ' ')) fileInputRef.current?.click(); }}
|
|
className={`relative flex flex-col items-center justify-center w-full border-2 border-dashed rounded-xl transition-colors cursor-pointer overflow-hidden ${
|
|
isDragOver
|
|
? 'border-primary bg-primary/5'
|
|
: 'border-border bg-secondary/5 hover:bg-secondary/10'
|
|
} ${emojis.length > 0 ? 'h-20' : 'h-28'} ${isSubmitting ? 'pointer-events-none opacity-50' : ''}`}
|
|
>
|
|
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
|
|
<Upload className="size-4 opacity-50" />
|
|
<span className="text-xs text-center px-4">
|
|
Drop images or a folder here, or click to browse
|
|
</span>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleFileInputChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Emoji list */}
|
|
{emojis.length > 0 && (
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-muted-foreground">
|
|
{emojis.length} emoji{emojis.length !== 1 ? 's' : ''}
|
|
</span>
|
|
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
|
|
<SortableList
|
|
items={emojis}
|
|
getItemId={(emoji) => emoji.id}
|
|
onReorder={handleReorder}
|
|
renderItem={(emoji) => (
|
|
<SortableItem
|
|
key={emoji.id}
|
|
id={emoji.id}
|
|
enabled={!isSubmitting}
|
|
className="items-center bg-background"
|
|
draggingClassName="z-10 opacity-80 shadow-lg ring-2 ring-primary/20"
|
|
gripClassName="w-7"
|
|
>
|
|
<div className="flex items-center gap-2 pr-2 py-1.5">
|
|
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
|
|
<CustomEmojiImg
|
|
name={emoji.shortcode}
|
|
url={emoji.url}
|
|
className="size-8 object-contain"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<Input
|
|
value={emoji.shortcode}
|
|
onChange={(e) => updateShortcode(emoji.id, e.target.value)}
|
|
className="h-7 text-xs font-mono px-1.5 border-none shadow-none focus-visible:ring-1"
|
|
placeholder="shortcode"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeEmoji(emoji.id)}
|
|
disabled={isSubmitting}
|
|
>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</SortableItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Publish button */}
|
|
<Button
|
|
onClick={handlePublish}
|
|
disabled={!canPublish}
|
|
className="w-full gap-2"
|
|
>
|
|
{isSubmitting ? (
|
|
<><Loader2 className="size-4 animate-spin" /> {pendingCount > 0 ? `Uploading ${pendingCount} file${pendingCount !== 1 ? 's' : ''}...` : 'Publishing...'}</>
|
|
) : (
|
|
<><Smile className="size-4" /> {isEditMode ? 'Update Emoji Pack' : 'Create Emoji Pack'}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|