37 changed files with 1941 additions and 140 deletions
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { |
||||
contentNeedsAuthorEmojiLookup, |
||||
mergeEmojiInfosEventOverridesAuthor |
||||
} from './useEmojiInfosForEvent' |
||||
|
||||
describe('mergeEmojiInfosEventOverridesAuthor', () => { |
||||
it('lets event shortcodes override author', () => { |
||||
const merged = mergeEmojiInfosEventOverridesAuthor( |
||||
[{ shortcode: 'x', url: 'https://a/a.png' }], |
||||
[{ shortcode: 'x', url: 'https://b/b.png' }] |
||||
) |
||||
expect(merged).toHaveLength(1) |
||||
expect(merged[0]?.url).toBe('https://b/b.png') |
||||
}) |
||||
|
||||
it('merges distinct shortcodes', () => { |
||||
const merged = mergeEmojiInfosEventOverridesAuthor( |
||||
[{ shortcode: 'a', url: 'https://a' }], |
||||
[{ shortcode: 'b', url: 'https://b' }] |
||||
) |
||||
expect(merged.map((e) => e.shortcode).sort()).toEqual(['a', 'b']) |
||||
}) |
||||
}) |
||||
|
||||
describe('contentNeedsAuthorEmojiLookup', () => { |
||||
it('returns false when only standard shortcodes and no event emojis', () => { |
||||
expect(contentNeedsAuthorEmojiLookup('hi :smile: bye', [])).toBe(false) |
||||
}) |
||||
|
||||
it('returns false when custom is on event tags', () => { |
||||
expect( |
||||
contentNeedsAuthorEmojiLookup('hi :chad_yes: bye', [{ shortcode: 'chad_yes', url: 'https://x' }]) |
||||
).toBe(false) |
||||
}) |
||||
|
||||
it('returns true for unknown shortcode without event tag', () => { |
||||
expect(contentNeedsAuthorEmojiLookup(':chad_yes:', [])).toBe(true) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
|
||||
import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' |
||||
import { |
||||
fetchAuthorNip30EmojiInfos, |
||||
fetchAuthorNip30EmojiInfosFromIndexedDb |
||||
} from '@/lib/nip30-author-emojis' |
||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' |
||||
import { TEmoji } from '@/types' |
||||
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' |
||||
import { type Event } from 'nostr-tools' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
|
||||
/** Event `emoji` tags override the same shortcode from the author's kind 0. */ |
||||
export function mergeEmojiInfosEventOverridesAuthor( |
||||
fromAuthor: TEmoji[], |
||||
fromEvent: TEmoji[] |
||||
): TEmoji[] { |
||||
const m = new Map<string, TEmoji>() |
||||
for (const e of fromAuthor) m.set(e.shortcode, e) |
||||
for (const e of fromEvent) m.set(e.shortcode, e) |
||||
return [...m.values()] |
||||
} |
||||
|
||||
/** |
||||
* True when `content` contains a `:shortcode:` that is neither defined on the event nor a known |
||||
* standard (Unicode) shortcode — likely a custom emoji from the author's profile. |
||||
*/ |
||||
export function contentNeedsAuthorEmojiLookup(content: string | undefined, eventTagInfos: TEmoji[]): boolean { |
||||
if (!content) return false |
||||
const eventCodes = new Set(eventTagInfos.map((e) => e.shortcode)) |
||||
const re = new RegExp(EMOJI_SHORT_CODE_REGEX.source, 'g') |
||||
let m: RegExpExecArray | null |
||||
while ((m = re.exec(content)) !== null) { |
||||
const code = m[1].trim() |
||||
if (eventCodes.has(code)) continue |
||||
const native = shortcodeToEmoji(code, emojis) ?? shortcodeToEmoji(code.replace(/\s+/g, '_'), emojis) |
||||
if (!native?.emoji) return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
/** |
||||
* NIP-30 emoji tags on the event plus, when needed, the author’s published custom emoji |
||||
* (kind 0, 10030, and 30030 — same inventory approach as the emoji picker). |
||||
*/ |
||||
export function useEmojiInfosForEvent(event: Event | undefined | null): TEmoji[] { |
||||
const fromEvent = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags ?? []), [event?.tags]) |
||||
const needsLookup = useMemo( |
||||
() => (event ? contentNeedsAuthorEmojiLookup(event.content, fromEvent) : false), |
||||
[event?.id, event?.content, fromEvent] |
||||
) |
||||
const pubkey = event?.pubkey?.trim().toLowerCase() ?? '' |
||||
const validPk = /^[0-9a-f]{64}$/.test(pubkey) |
||||
|
||||
const [fromAuthor, setFromAuthor] = useState<TEmoji[]>([]) |
||||
|
||||
useEffect(() => { |
||||
if (!needsLookup || !validPk) { |
||||
setFromAuthor([]) |
||||
return |
||||
} |
||||
let cancelled = false |
||||
let fullResolved = false |
||||
void fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey).then((infos) => { |
||||
if (cancelled || fullResolved) return |
||||
setFromAuthor(infos) |
||||
}) |
||||
void fetchAuthorNip30EmojiInfos(pubkey) |
||||
.then((infos) => { |
||||
if (cancelled) return |
||||
fullResolved = true |
||||
setFromAuthor(infos) |
||||
}) |
||||
.catch(() => { |
||||
fullResolved = true |
||||
}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [needsLookup, validPk, pubkey]) |
||||
|
||||
return useMemo( |
||||
() => mergeEmojiInfosEventOverridesAuthor(fromAuthor, fromEvent), |
||||
[fromAuthor, fromEvent] |
||||
) |
||||
} |
||||
@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
|
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import type { TEmoji } from '@/types' |
||||
import { kinds, type Event } from 'nostr-tools' |
||||
|
||||
export function getEmojiSetDTag(event: Event): string | undefined { |
||||
return event.tags.find(tagNameEquals('d'))?.[1] |
||||
} |
||||
|
||||
export function labelEmojiSetEvent(event: Event): string { |
||||
const title = event.tags.find(tagNameEquals('title'))?.[1]?.trim() |
||||
if (title) return title |
||||
const d = getEmojiSetDTag(event) |
||||
return d ?? 'emoji set' |
||||
} |
||||
|
||||
export function isEmojiSetPointerTag(tag: string[]): boolean { |
||||
if (tag[0] !== 'a' || !tag[1]) return false |
||||
const k = parseInt(tag[1].split(':')[0] ?? '', 10) |
||||
return k === kinds.Emojisets |
||||
} |
||||
|
||||
/** Tags on kind 10030 other than inline `emoji` entries and `a` → 30030 pointers. */ |
||||
export function preservedTagsFromUserEmojiListEvent(event: Event | null): string[][] { |
||||
if (!event) return [] |
||||
return event.tags.filter((t) => { |
||||
if (t[0] === 'emoji') return false |
||||
if (isEmojiSetPointerTag(t)) return false |
||||
return true |
||||
}) |
||||
} |
||||
|
||||
/** Normalize `30030:<hex64>:<d>` for an `a` tag value (pubkey lowercased). */ |
||||
export function normalizeEmojiSetATagValue(raw: string): string | null { |
||||
const s = raw.trim().replace(/\s+/g, '') |
||||
const m = /^(\d+):([0-9a-f]{64}):([\s\S]*)$/i.exec(s) |
||||
if (!m) return null |
||||
const kind = parseInt(m[1], 10) |
||||
if (kind !== kinds.Emojisets) return null |
||||
const pk = m[2].toLowerCase() |
||||
return `${kinds.Emojisets}:${pk}:${m[3]}` |
||||
} |
||||
|
||||
export function buildEmojiSetTags(params: { |
||||
d: string |
||||
title?: string |
||||
description?: string |
||||
image?: string |
||||
emojis: TEmoji[] |
||||
}): string[][] { |
||||
const d = params.d.trim() |
||||
if (!d) throw new Error('Invalid list id') |
||||
const tags: string[][] = [['d', d]] |
||||
const title = params.title?.trim() |
||||
if (title) tags.push(['title', title]) |
||||
const description = params.description?.trim() |
||||
if (description) tags.push(['description', description]) |
||||
const image = params.image?.trim() |
||||
if (image) tags.push(['image', image]) |
||||
for (const e of params.emojis) { |
||||
const sc = e.shortcode.trim().replace(/^:+|:+$/gu, '') |
||||
const url = e.url.trim() |
||||
if (!sc || !url) continue |
||||
tags.push(['emoji', sc, url]) |
||||
} |
||||
return tags |
||||
} |
||||
|
||||
export function extractEmojiSetEditorFields(event: Event): { |
||||
d: string |
||||
title: string |
||||
description: string |
||||
image: string |
||||
emojis: TEmoji[] |
||||
} { |
||||
const emojis: TEmoji[] = [] |
||||
for (const t of event.tags) { |
||||
if (t[0] === 'emoji' && t[1] && t[2]) { |
||||
emojis.push({ shortcode: t[1], url: t[2] }) |
||||
} |
||||
} |
||||
return { |
||||
d: getEmojiSetDTag(event) ?? '', |
||||
title: event.tags.find(tagNameEquals('title'))?.[1] ?? '', |
||||
description: event.tags.find(tagNameEquals('description'))?.[1] ?? '', |
||||
image: event.tags.find(tagNameEquals('image'))?.[1] ?? '', |
||||
emojis |
||||
} |
||||
} |
||||
|
||||
export function dedupeEmojiSetEventsByD(events: Event[]): Event[] { |
||||
const byD = new Map<string, Event>() |
||||
for (const e of [...events].sort((a, b) => b.created_at - a.created_at)) { |
||||
const d = getEmojiSetDTag(e) |
||||
if (!d) continue |
||||
if (!byD.has(d)) byD.set(d, e) |
||||
} |
||||
return [...byD.values()].sort((a, b) => |
||||
labelEmojiSetEvent(a).localeCompare(labelEmojiSetEvent(b), undefined, { sensitivity: 'base' }) |
||||
) |
||||
} |
||||
@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
|
||||
import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata' |
||||
import client from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import { TEmoji } from '@/types' |
||||
import type { Event } from 'nostr-tools' |
||||
import { kinds } from 'nostr-tools' |
||||
|
||||
function addEmojis(map: Map<string, TEmoji>, list: TEmoji[]) { |
||||
for (const e of list) { |
||||
const sc = e.shortcode?.trim() |
||||
const url = e.url?.trim() |
||||
if (sc && url) map.set(sc, { shortcode: sc, url }) |
||||
} |
||||
} |
||||
|
||||
async function collectAuthorEmojiEventsFromIndexedDb(pk: string): Promise<Event[]> { |
||||
const [idbMeta, idbList, idbSets] = await Promise.all([ |
||||
indexedDb.getReplaceableEvent(pk, kinds.Metadata).catch(() => null), |
||||
indexedDb.getReplaceableEvent(pk, kinds.UserEmojiList).catch(() => null), |
||||
indexedDb.getEmojiSetEventsForPubkey(pk).catch(() => [] as Event[]) |
||||
]) |
||||
const merged: Event[] = [] |
||||
const pushIf = (ev: Event | null | undefined) => { |
||||
if (ev?.id) merged.push(ev) |
||||
} |
||||
pushIf(idbMeta ?? undefined) |
||||
pushIf(idbList ?? undefined) |
||||
for (const ev of idbSets) pushIf(ev) |
||||
return merged |
||||
} |
||||
|
||||
/** |
||||
* NIP-30 custom emoji defined by an author: kind 0 `emoji` tags, kind 10030 list (+ `a` → 30030), |
||||
* and kind 30030 packs (aligned with the custom emoji picker’s inventory fetch). |
||||
*/ |
||||
async function emojiInfosFromAuthorEvents(events: Event[], pk: string): Promise<TEmoji[]> { |
||||
const byShortcode = new Map<string, TEmoji>() |
||||
|
||||
const latestOfKind = (kind: number): Event | undefined => |
||||
events |
||||
.filter((e) => e.kind === kind && e.pubkey.trim().toLowerCase() === pk) |
||||
.sort((a, b) => b.created_at - a.created_at)[0] |
||||
|
||||
const meta = latestOfKind(kinds.Metadata) |
||||
if (meta) addEmojis(byShortcode, getEmojisFromEvent(meta)) |
||||
|
||||
const latestList = latestOfKind(kinds.UserEmojiList) |
||||
if (latestList) { |
||||
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(latestList) |
||||
addEmojis(byShortcode, emojis) |
||||
const setEvents = await client.fetchEmojiSetEvents(emojiSetPointers) |
||||
for (const se of setEvents) { |
||||
if (se) addEmojis(byShortcode, getEmojisFromEvent(se)) |
||||
} |
||||
} |
||||
|
||||
for (const ev of events) { |
||||
if (ev.kind === kinds.Emojisets && ev.pubkey.trim().toLowerCase() === pk) { |
||||
addEmojis(byShortcode, getEmojisFromEvent(ev)) |
||||
} |
||||
} |
||||
|
||||
return [...byShortcode.values()] |
||||
} |
||||
|
||||
async function loadAuthorNip30EmojiInfosUncached(pubkey: string): Promise<TEmoji[]> { |
||||
const pk = pubkey.trim().toLowerCase() |
||||
if (!/^[0-9a-f]{64}$/.test(pk)) return [] |
||||
|
||||
const [remote, idbEvents] = await Promise.all([ |
||||
client.fetchAuthorEmojiInventory(pk).catch(() => [] as Event[]), |
||||
collectAuthorEmojiEventsFromIndexedDb(pk) |
||||
]) |
||||
const merged: Event[] = [...remote] |
||||
for (const ev of idbEvents) { |
||||
if (ev?.id) merged.push(ev) |
||||
} |
||||
|
||||
return emojiInfosFromAuthorEvents(merged, pk) |
||||
} |
||||
|
||||
async function loadAuthorNip30FromIndexedDbUncached(pubkey: string): Promise<TEmoji[]> { |
||||
const pk = pubkey.trim().toLowerCase() |
||||
if (!/^[0-9a-f]{64}$/.test(pk)) return [] |
||||
const events = await collectAuthorEmojiEventsFromIndexedDb(pk) |
||||
return emojiInfosFromAuthorEvents(events, pk) |
||||
} |
||||
|
||||
const inflightAuthorEmoji = new Map<string, Promise<TEmoji[]>>() |
||||
const inflightAuthorEmojiIdb = new Map<string, Promise<TEmoji[]>>() |
||||
|
||||
export function fetchAuthorNip30EmojiInfos(pubkey: string): Promise<TEmoji[]> { |
||||
const pk = pubkey.trim().toLowerCase() |
||||
if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) |
||||
|
||||
const existing = inflightAuthorEmoji.get(pk) |
||||
if (existing) return existing |
||||
|
||||
const p = loadAuthorNip30EmojiInfosUncached(pk).finally(() => { |
||||
if (inflightAuthorEmoji.get(pk) === p) inflightAuthorEmoji.delete(pk) |
||||
}) |
||||
inflightAuthorEmoji.set(pk, p) |
||||
return p |
||||
} |
||||
|
||||
/** IndexedDB only — no relay inventory query; use with {@link fetchAuthorNip30EmojiInfos} for a full refresh. */ |
||||
export function fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey: string): Promise<TEmoji[]> { |
||||
const pk = pubkey.trim().toLowerCase() |
||||
if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) |
||||
|
||||
const existing = inflightAuthorEmojiIdb.get(pk) |
||||
if (existing) return existing |
||||
|
||||
const p = loadAuthorNip30FromIndexedDbUncached(pk).finally(() => { |
||||
if (inflightAuthorEmojiIdb.get(pk) === p) inflightAuthorEmojiIdb.delete(pk) |
||||
}) |
||||
inflightAuthorEmojiIdb.set(pk, p) |
||||
return p |
||||
} |
||||
@ -0,0 +1,525 @@
@@ -0,0 +1,525 @@
|
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import { |
||||
AlertDialog, |
||||
AlertDialogAction, |
||||
AlertDialogCancel, |
||||
AlertDialogContent, |
||||
AlertDialogDescription, |
||||
AlertDialogFooter, |
||||
AlertDialogHeader, |
||||
AlertDialogTitle |
||||
} from '@/components/ui/alert-dialog' |
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' |
||||
import { |
||||
buildEmojiSetTags, |
||||
dedupeEmojiSetEventsByD, |
||||
extractEmojiSetEditorFields, |
||||
labelEmojiSetEvent |
||||
} from '@/lib/emoji-set-editor' |
||||
import { randomString } from '@/lib/random' |
||||
import { showPublishingError } from '@/lib/publishing-feedback' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||
import { |
||||
getRelayUrlsWithFavoritesFastReadAndInbox, |
||||
userReadRelaysWithHttp |
||||
} from '@/lib/favorites-feed-relays' |
||||
import { createEmojiSetDraftEvent } from '@/lib/draft-event' |
||||
import { filterEventsExcludingTombstones } from '@/lib/event' |
||||
import logger from '@/lib/logger' |
||||
import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import customEmojiService from '@/services/custom-emoji.service' |
||||
import { queryService, replaceableEventService } from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import dayjs from 'dayjs' |
||||
import type { TEmoji } from '@/types' |
||||
import type { Event } from 'nostr-tools' |
||||
import { kinds } from 'nostr-tools' |
||||
import { Eraser, Pencil, Plus, Sticker, Trash2 } from 'lucide-react' |
||||
import { forwardRef, useCallback, useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
|
||||
const EMOJI_SET_FETCH_OPTS = { |
||||
eoseTimeout: 2000, |
||||
globalTimeout: 15000, |
||||
firstRelayResultGraceMs: false |
||||
} as const |
||||
|
||||
const EmojiSetsSettingsPage = forwardRef( |
||||
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { pubkey, account, publish, attemptDelete, checkLogin, relayList, userEmojiListEvent, profileEvent } = |
||||
useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const [lists, setLists] = useState<Event[]>([]) |
||||
const [loading, setLoading] = useState(true) |
||||
const [dialogOpen, setDialogOpen] = useState(false) |
||||
const [saving, setSaving] = useState(false) |
||||
const [editing, setEditing] = useState<Event | null>(null) |
||||
const [formD, setFormD] = useState('') |
||||
const [formTitle, setFormTitle] = useState('') |
||||
const [formDescription, setFormDescription] = useState('') |
||||
const [formImage, setFormImage] = useState('') |
||||
const [formEmojis, setFormEmojis] = useState<TEmoji[]>([]) |
||||
const [newShortcode, setNewShortcode] = useState('') |
||||
const [newUrl, setNewUrl] = useState('') |
||||
const [deleteTarget, setDeleteTarget] = useState<Event | null>(null) |
||||
const [deleting, setDeleting] = useState(false) |
||||
const [cleanTarget, setCleanTarget] = useState<Event | null>(null) |
||||
const [cleaning, setCleaning] = useState(false) |
||||
|
||||
const canSignEvents = account != null && account.signerType !== 'npub' |
||||
|
||||
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
||||
|
||||
const buildReadRelays = useCallback((): string[] => { |
||||
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
userReadRelaysWithHttp(relayList), |
||||
{ userWriteRelays: relayList?.write ?? [] } |
||||
) |
||||
return appendCuratedReadOnlyRelays(feedUrls, blockedRelays) |
||||
}, [favoriteRelays, blockedRelays, relayList]) |
||||
|
||||
const loadLists = useCallback(async () => { |
||||
if (!pubkey) { |
||||
setLists([]) |
||||
setLoading(false) |
||||
return |
||||
} |
||||
setLoading(true) |
||||
try { |
||||
const urls = buildReadRelays() |
||||
if (!urls.length) { |
||||
setLists([]) |
||||
return |
||||
} |
||||
const events = await queryService.fetchEvents( |
||||
urls, |
||||
{ authors: [pubkey], kinds: [kinds.Emojisets], limit: 500 }, |
||||
EMOJI_SET_FETCH_OPTS |
||||
) |
||||
const tombstones = await indexedDb.getAllTombstones() |
||||
setLists(dedupeEmojiSetEventsByD(filterEventsExcludingTombstones(events, tombstones))) |
||||
} catch (e) { |
||||
logger.warn('[EmojiSetsSettings] Failed to load emoji sets', e) |
||||
toast.error(t('Failed to load emoji sets')) |
||||
setLists([]) |
||||
} finally { |
||||
setLoading(false) |
||||
} |
||||
}, [pubkey, buildReadRelays, t]) |
||||
|
||||
useEffect(() => { |
||||
void loadLists() |
||||
}, [loadLists]) |
||||
|
||||
useEffect(() => { |
||||
const onTombstones = () => void loadLists() |
||||
window.addEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) |
||||
return () => window.removeEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) |
||||
}, [loadLists]) |
||||
|
||||
useEffect(() => { |
||||
if (!hideTitlebar) { |
||||
registerPrimaryPanelRefresh(null) |
||||
return |
||||
} |
||||
registerPrimaryPanelRefresh(() => void loadLists()) |
||||
return () => registerPrimaryPanelRefresh(null) |
||||
}, [hideTitlebar, registerPrimaryPanelRefresh, loadLists]) |
||||
|
||||
const openNew = () => { |
||||
setEditing(null) |
||||
setFormD(randomString(16)) |
||||
setFormTitle('') |
||||
setFormDescription('') |
||||
setFormImage('') |
||||
setFormEmojis([]) |
||||
setNewShortcode('') |
||||
setNewUrl('') |
||||
setDialogOpen(true) |
||||
} |
||||
|
||||
const openEdit = (ev: Event) => { |
||||
const f = extractEmojiSetEditorFields(ev) |
||||
setEditing(ev) |
||||
setFormD(f.d) |
||||
setFormTitle(f.title) |
||||
setFormDescription(f.description) |
||||
setFormImage(f.image) |
||||
setFormEmojis([...f.emojis]) |
||||
setNewShortcode('') |
||||
setNewUrl('') |
||||
setDialogOpen(true) |
||||
} |
||||
|
||||
const closeDialog = () => { |
||||
setDialogOpen(false) |
||||
setEditing(null) |
||||
} |
||||
|
||||
const addEmojiRow = (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
const sc = newShortcode.trim().replace(/^:+|:+$/gu, '') |
||||
const url = newUrl.trim() |
||||
if (!sc || !url) return |
||||
setFormEmojis((prev) => [...prev, { shortcode: sc, url }]) |
||||
setNewShortcode('') |
||||
setNewUrl('') |
||||
} |
||||
|
||||
const handleSave = async () => { |
||||
await checkLogin(async () => { |
||||
if (!pubkey) return |
||||
let tags: string[][] |
||||
try { |
||||
tags = buildEmojiSetTags({ |
||||
d: formD, |
||||
title: formTitle, |
||||
description: formDescription, |
||||
image: formImage, |
||||
emojis: formEmojis |
||||
}) |
||||
} catch (err) { |
||||
toast.error((err as Error).message) |
||||
return |
||||
} |
||||
|
||||
setSaving(true) |
||||
try { |
||||
let createdAt = dayjs().unix() |
||||
if (editing && createdAt === editing.created_at) { |
||||
await new Promise((r) => setTimeout(r, 1100)) |
||||
createdAt = dayjs().unix() |
||||
} |
||||
const draft = createEmojiSetDraftEvent(tags, '', createdAt) |
||||
const published = await publish(draft) |
||||
const ev = published as Event |
||||
try { |
||||
await indexedDb.putReplaceableEvent(ev) |
||||
} catch { |
||||
/* ignore tombstone / IDB */ |
||||
} |
||||
void replaceableEventService.updateReplaceableEventCache(ev).catch(() => {}) |
||||
await customEmojiService.init(userEmojiListEvent, pubkey, profileEvent, [ev]) |
||||
toast.success(t('Emoji set saved')) |
||||
closeDialog() |
||||
await loadLists() |
||||
} catch (e) { |
||||
showPublishingError(e instanceof Error ? e : new Error(String(e))) |
||||
} finally { |
||||
setSaving(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const handleConfirmDelete = async () => { |
||||
if (!deleteTarget) return |
||||
await checkLogin(async () => { |
||||
setDeleting(true) |
||||
try { |
||||
await attemptDelete(deleteTarget) |
||||
toast.success(t('Emoji set deleted')) |
||||
setDeleteTarget(null) |
||||
await loadLists() |
||||
await customEmojiService.init(userEmojiListEvent, pubkey, profileEvent) |
||||
} catch (e) { |
||||
showPublishingError(e instanceof Error ? e : new Error(String(e))) |
||||
} finally { |
||||
setDeleting(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const handleConfirmClean = async () => { |
||||
if (!cleanTarget) return |
||||
await checkLogin(async () => { |
||||
setCleaning(true) |
||||
try { |
||||
const fields = extractEmojiSetEditorFields(cleanTarget) |
||||
let createdAt = dayjs().unix() |
||||
if (createdAt === cleanTarget.created_at) { |
||||
await new Promise((r) => setTimeout(r, 1100)) |
||||
createdAt = dayjs().unix() |
||||
} |
||||
const tags = buildEmojiSetTags({ d: fields.d, title: fields.title, description: fields.description, image: fields.image, emojis: [] }) |
||||
const draft = createEmojiSetDraftEvent(tags, '', createdAt) |
||||
const published = await publish(draft) |
||||
const ev = published as Event |
||||
try { |
||||
await indexedDb.putReplaceableEvent(ev) |
||||
} catch { |
||||
/* ignore tombstone / IDB */ |
||||
} |
||||
void replaceableEventService.updateReplaceableEventCache(ev).catch(() => {}) |
||||
await customEmojiService.init(userEmojiListEvent, pubkey, profileEvent, [ev]) |
||||
toast.success(t('List cleaned')) |
||||
setCleanTarget(null) |
||||
await loadLists() |
||||
} catch (e) { |
||||
showPublishingError(e instanceof Error ? e : new Error(String(e))) |
||||
} finally { |
||||
setCleaning(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout |
||||
ref={ref} |
||||
index={index} |
||||
title={hideTitlebar ? undefined : t('Emoji sets')} |
||||
hideBackButton={hideTitlebar} |
||||
controls={hideTitlebar ? undefined : <RefreshButton onClick={() => void loadLists()} />} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="min-w-0 space-y-4 px-4 pb-8 pt-2"> |
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t('Emoji sets settings intro')}</p> |
||||
|
||||
{!pubkey ? ( |
||||
<p className="text-sm text-muted-foreground">{t('Login to set')}</p> |
||||
) : ( |
||||
<> |
||||
<div className="flex flex-wrap gap-2"> |
||||
<Button type="button" onClick={openNew} className="gap-2"> |
||||
<Plus className="size-4" /> |
||||
{t('New emoji set')} |
||||
</Button> |
||||
</div> |
||||
|
||||
{loading ? ( |
||||
<div className="space-y-2"> |
||||
<Skeleton className="h-16 w-full" /> |
||||
<Skeleton className="h-16 w-full" /> |
||||
</div> |
||||
) : lists.length === 0 ? ( |
||||
<p className="text-sm text-muted-foreground">{t('No emoji sets yet')}</p> |
||||
) : ( |
||||
<ul className="space-y-2"> |
||||
{lists.map((ev) => ( |
||||
<li |
||||
key={extractEmojiSetEditorFields(ev).d} |
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/80 bg-card px-3 py-3" |
||||
> |
||||
<div className="flex min-w-0 flex-1 items-center gap-2"> |
||||
<Sticker className="size-4 shrink-0 text-muted-foreground" /> |
||||
<div className="min-w-0"> |
||||
<div className="truncate font-medium">{labelEmojiSetEvent(ev)}</div> |
||||
<div className="truncate text-xs text-muted-foreground"> |
||||
{extractEmojiSetEditorFields(ev).emojis.length} {t('emoji entries')} |
||||
<span className="mx-1">·</span> |
||||
<code className="text-[11px]">d={extractEmojiSetEditorFields(ev).d}</code> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className="flex shrink-0 gap-1"> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
size="sm" |
||||
onClick={() => setCleanTarget(ev)} |
||||
title={t('Clean list')} |
||||
className="text-destructive hover:text-destructive" |
||||
> |
||||
<Eraser className="size-4" /> |
||||
<span className="sr-only">{t('Clean list')}</span> |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
size="sm" |
||||
onClick={() => openEdit(ev)} |
||||
title={t('Edit')} |
||||
> |
||||
<Pencil className="size-4" /> |
||||
<span className="sr-only">{t('Edit')}</span> |
||||
</Button> |
||||
{canSignEvents && ev.pubkey === pubkey ? ( |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
size="sm" |
||||
className="text-destructive hover:text-destructive" |
||||
onClick={() => setDeleteTarget(ev)} |
||||
title={t('Delete')} |
||||
> |
||||
<Trash2 className="size-4" /> |
||||
<span className="sr-only">{t('Delete')}</span> |
||||
</Button> |
||||
) : null} |
||||
</div> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
)} |
||||
</> |
||||
)} |
||||
</div> |
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(o) => !o && closeDialog()}> |
||||
<DialogContent className="max-h-[min(90dvh,36rem)] overflow-y-auto sm:max-w-lg"> |
||||
<DialogHeader> |
||||
<DialogTitle>{editing ? t('Edit emoji set') : t('New emoji set')}</DialogTitle> |
||||
</DialogHeader> |
||||
<div className="space-y-4 py-2"> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="emoji-set-d">{t('List id (d tag)')}</Label> |
||||
<Input |
||||
id="emoji-set-d" |
||||
value={formD} |
||||
onChange={(e) => setFormD(e.target.value)} |
||||
disabled={!!editing} |
||||
className="font-mono text-sm" |
||||
/> |
||||
<p className="text-xs text-muted-foreground">{t('Emoji set d tag hint')}</p> |
||||
</div> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="emoji-set-title">{t('Title')}</Label> |
||||
<Input |
||||
id="emoji-set-title" |
||||
value={formTitle} |
||||
onChange={(e) => setFormTitle(e.target.value)} |
||||
placeholder={t('Optional display title')} |
||||
/> |
||||
</div> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="emoji-set-desc">{t('Description')}</Label> |
||||
<Textarea |
||||
id="emoji-set-desc" |
||||
value={formDescription} |
||||
onChange={(e) => setFormDescription(e.target.value)} |
||||
rows={2} |
||||
placeholder={t('Optional')} |
||||
/> |
||||
</div> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="emoji-set-image">{t('Image URL')}</Label> |
||||
<Input |
||||
id="emoji-set-image" |
||||
value={formImage} |
||||
onChange={(e) => setFormImage(e.target.value)} |
||||
placeholder="https://…" |
||||
/> |
||||
</div> |
||||
<div className="space-y-2"> |
||||
<Label>{t('Emoji pack entries')}</Label> |
||||
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-md border border-border/80 p-2"> |
||||
{formEmojis.length === 0 ? ( |
||||
<li className="text-sm text-muted-foreground">{t('No emoji entries in pack')}</li> |
||||
) : ( |
||||
formEmojis.map((em, idx) => ( |
||||
<li key={`${em.shortcode}-${idx}`} className="flex items-center justify-between gap-2 text-sm"> |
||||
<span className="min-w-0 truncate"> |
||||
<code>:{em.shortcode}:</code>{' '} |
||||
<span className="text-muted-foreground">{em.url}</span> |
||||
</span> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="sm" |
||||
className="shrink-0 text-destructive" |
||||
onClick={() => setFormEmojis((prev) => prev.filter((_, j) => j !== idx))} |
||||
> |
||||
{t('Remove')} |
||||
</Button> |
||||
</li> |
||||
)) |
||||
)} |
||||
</ul> |
||||
<form onSubmit={addEmojiRow} className="flex flex-col gap-2 sm:flex-row sm:items-end"> |
||||
<div className="grid flex-1 gap-2 sm:grid-cols-2"> |
||||
<Input |
||||
value={newShortcode} |
||||
onChange={(e) => setNewShortcode(e.target.value)} |
||||
placeholder={t('Shortcode')} |
||||
/> |
||||
<Input |
||||
value={newUrl} |
||||
onChange={(e) => setNewUrl(e.target.value)} |
||||
placeholder={t('Image URL')} |
||||
/> |
||||
</div> |
||||
<Button type="submit" variant="secondary"> |
||||
{t('Add')} |
||||
</Button> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
<DialogFooter className="gap-2 sm:gap-0"> |
||||
<Button type="button" variant="outline" onClick={closeDialog}> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button type="button" onClick={() => void handleSave()} disabled={saving || !formD.trim()}> |
||||
{saving ? t('loading...') : t('Save')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</DialogContent> |
||||
</Dialog> |
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}> |
||||
<AlertDialogContent> |
||||
<AlertDialogHeader> |
||||
<AlertDialogTitle>{t('Delete emoji set?')}</AlertDialogTitle> |
||||
<AlertDialogDescription>{t('Delete emoji set confirm')}</AlertDialogDescription> |
||||
</AlertDialogHeader> |
||||
<AlertDialogFooter> |
||||
<AlertDialogCancel disabled={deleting}>{t('Cancel')}</AlertDialogCancel> |
||||
<AlertDialogAction |
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" |
||||
disabled={deleting} |
||||
onClick={(e) => { |
||||
e.preventDefault() |
||||
void handleConfirmDelete() |
||||
}} |
||||
> |
||||
{deleting ? t('loading...') : t('Delete')} |
||||
</AlertDialogAction> |
||||
</AlertDialogFooter> |
||||
</AlertDialogContent> |
||||
</AlertDialog> |
||||
|
||||
<AlertDialog open={!!cleanTarget} onOpenChange={(o) => !o && setCleanTarget(null)}> |
||||
<AlertDialogContent> |
||||
<AlertDialogHeader> |
||||
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle> |
||||
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription> |
||||
</AlertDialogHeader> |
||||
<AlertDialogFooter> |
||||
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel> |
||||
<AlertDialogAction |
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" |
||||
disabled={cleaning} |
||||
onClick={(e) => { |
||||
e.preventDefault() |
||||
void handleConfirmClean() |
||||
}} |
||||
> |
||||
{cleaning ? t('loading...') : t('Clean list')} |
||||
</AlertDialogAction> |
||||
</AlertDialogFooter> |
||||
</AlertDialogContent> |
||||
</AlertDialog> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
) |
||||
|
||||
EmojiSetsSettingsPage.displayName = 'EmojiSetsSettingsPage' |
||||
export default EmojiSetsSettingsPage |
||||
@ -0,0 +1,470 @@
@@ -0,0 +1,470 @@
|
||||
import JsonViewDialog from '@/components/JsonViewDialog' |
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import { |
||||
AlertDialog, |
||||
AlertDialogAction, |
||||
AlertDialogCancel, |
||||
AlertDialogContent, |
||||
AlertDialogDescription, |
||||
AlertDialogFooter, |
||||
AlertDialogHeader, |
||||
AlertDialogTitle |
||||
} from '@/components/ui/alert-dialog' |
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
DropdownMenu, |
||||
DropdownMenuContent, |
||||
DropdownMenuItem, |
||||
DropdownMenuTrigger |
||||
} from '@/components/ui/dropdown-menu' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' |
||||
import { createUserEmojiListDraftEvent } from '@/lib/draft-event' |
||||
import { |
||||
isEmojiSetPointerTag, |
||||
normalizeEmojiSetATagValue, |
||||
preservedTagsFromUserEmojiListEvent |
||||
} from '@/lib/emoji-set-editor' |
||||
import { getEmojisFromEvent } from '@/lib/event-metadata' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { showPublishingError } from '@/lib/publishing-feedback' |
||||
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import dayjs from 'dayjs' |
||||
import { Code, Eraser, MoreVertical, Trash2 } from 'lucide-react' |
||||
import { kinds } from 'nostr-tools' |
||||
import type { Event } from 'nostr-tools' |
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import NotFoundPage from '../NotFoundPage' |
||||
|
||||
function normalizeShortcode(raw: string): string { |
||||
return raw.trim().replace(/^:+|:+$/gu, '') |
||||
} |
||||
|
||||
function parseEditorState(ev: Event | null): { inline: { shortcode: string; url: string }[]; aRefs: string[][] } { |
||||
if (!ev) return { inline: [], aRefs: [] } |
||||
return { |
||||
inline: getEmojisFromEvent(ev), |
||||
aRefs: ev.tags.filter((t) => isEmojiSetPointerTag(t)).map((t) => [...t]) |
||||
} |
||||
} |
||||
|
||||
const UserEmojiListPage = forwardRef( |
||||
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
||||
const { profile, pubkey, publish, checkLogin, updateUserEmojiListEvent } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const [listEvent, setListEvent] = useState<Event | null>(null) |
||||
const [inlineEmojis, setInlineEmojis] = useState<{ shortcode: string; url: string }[]>([]) |
||||
const [setATags, setSetATags] = useState<string[][]>([]) |
||||
const [newShortcode, setNewShortcode] = useState('') |
||||
const [newUrl, setNewUrl] = useState('') |
||||
const [newSetRef, setNewSetRef] = useState('') |
||||
const [publishing, setPublishing] = useState(false) |
||||
const [jsonOpen, setJsonOpen] = useState(false) |
||||
const [jsonPayload, setJsonPayload] = useState<unknown>(null) |
||||
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) |
||||
const [cleaning, setCleaning] = useState(false) |
||||
|
||||
const loadList = useCallback(async () => { |
||||
if (!pubkey) { |
||||
setListEvent(null) |
||||
setInlineEmojis([]) |
||||
setSetATags([]) |
||||
return |
||||
} |
||||
let cached: Event | null | undefined |
||||
try { |
||||
cached = (await indexedDb.getReplaceableEvent(pubkey, kinds.UserEmojiList)) ?? undefined |
||||
} catch { |
||||
cached = undefined |
||||
} |
||||
const relays = await buildAccountListRelayUrlsForMerge({ |
||||
accountPubkey: pubkey, |
||||
favoriteRelays: favoriteRelays ?? [], |
||||
blockedRelays |
||||
}) |
||||
const fromNet = await fetchLatestReplaceableListEvent(pubkey, kinds.UserEmojiList, relays) |
||||
const best = |
||||
!cached && fromNet |
||||
? fromNet |
||||
: cached && !fromNet |
||||
? cached |
||||
: cached && fromNet |
||||
? fromNet.created_at >= cached.created_at |
||||
? fromNet |
||||
: cached |
||||
: null |
||||
setListEvent(best ?? null) |
||||
const parsed = parseEditorState(best ?? null) |
||||
setInlineEmojis(parsed.inline) |
||||
setSetATags(parsed.aRefs) |
||||
if (best) { |
||||
try { |
||||
await indexedDb.putReplaceableEvent(best) |
||||
} catch { |
||||
/* ignore */ |
||||
} |
||||
} |
||||
}, [pubkey, favoriteRelays, blockedRelays]) |
||||
|
||||
useEffect(() => { |
||||
void loadList() |
||||
}, [loadList]) |
||||
|
||||
useEffect(() => { |
||||
if (!hideTitlebar) { |
||||
registerPrimaryPanelRefresh(null) |
||||
return |
||||
} |
||||
registerPrimaryPanelRefresh(() => { |
||||
void loadList() |
||||
}) |
||||
return () => registerPrimaryPanelRefresh(null) |
||||
}, [hideTitlebar, registerPrimaryPanelRefresh, loadList]) |
||||
|
||||
const buildTagsForPublish = useCallback((): string[][] => { |
||||
const preserved = preservedTagsFromUserEmojiListEvent(listEvent) |
||||
const emojiTags = inlineEmojis |
||||
.map((e) => { |
||||
const sc = normalizeShortcode(e.shortcode) |
||||
const url = e.url.trim() |
||||
if (!sc || !url) return null |
||||
return ['emoji', sc, url] as string[] |
||||
}) |
||||
.filter((row): row is string[] => row != null) |
||||
return [...preserved, ...emojiTags, ...setATags] |
||||
}, [listEvent, inlineEmojis, setATags]) |
||||
|
||||
const dirty = useMemo(() => { |
||||
const cur = parseEditorState(listEvent) |
||||
const sameInline = |
||||
cur.inline.length === inlineEmojis.length && |
||||
cur.inline.every( |
||||
(e, i) => |
||||
normalizeShortcode(e.shortcode) === normalizeShortcode(inlineEmojis[i]?.shortcode ?? '') && |
||||
e.url.trim() === (inlineEmojis[i]?.url ?? '').trim() |
||||
) |
||||
const key = (rows: string[][]) => |
||||
[...rows] |
||||
.map((r) => r.slice(0, 3).join('|')) |
||||
.sort() |
||||
.join('\n') |
||||
const sameA = key(cur.aRefs) === key(setATags) |
||||
return !sameInline || !sameA |
||||
}, [listEvent, inlineEmojis, setATags]) |
||||
|
||||
const publishList = async () => { |
||||
await checkLogin(async () => { |
||||
if (!pubkey) return |
||||
setPublishing(true) |
||||
try { |
||||
let createdAt = dayjs().unix() |
||||
if (listEvent && createdAt === listEvent.created_at) { |
||||
await new Promise((r) => setTimeout(r, 1100)) |
||||
createdAt = dayjs().unix() |
||||
} |
||||
const tags = buildTagsForPublish() |
||||
const content = listEvent?.content ?? '' |
||||
const draft = createUserEmojiListDraftEvent(tags, content, createdAt) |
||||
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({ |
||||
accountPubkey: pubkey, |
||||
favoriteRelays: favoriteRelays ?? [], |
||||
blockedRelays |
||||
}) |
||||
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays }) |
||||
setListEvent(published as Event) |
||||
await updateUserEmojiListEvent(published as Event) |
||||
const parsed = parseEditorState(published as Event) |
||||
setInlineEmojis(parsed.inline) |
||||
setSetATags(parsed.aRefs) |
||||
toast.success(t('User emoji list saved')) |
||||
} catch (e) { |
||||
showPublishingError(e instanceof Error ? e : new Error(String(e))) |
||||
} finally { |
||||
setPublishing(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const addInlineEmoji = (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
const sc = normalizeShortcode(newShortcode) |
||||
const url = newUrl.trim() |
||||
if (!sc || !url) { |
||||
toast.error(t('User emoji inline invalid')) |
||||
return |
||||
} |
||||
setInlineEmojis((prev) => [...prev, { shortcode: sc, url }]) |
||||
setNewShortcode('') |
||||
setNewUrl('') |
||||
} |
||||
|
||||
const addSetRef = (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
const norm = normalizeEmojiSetATagValue(newSetRef) |
||||
if (!norm) { |
||||
toast.error(t('User emoji set ref invalid')) |
||||
return |
||||
} |
||||
const nextTag = ['a', norm] |
||||
const seen = new Set( |
||||
setATags.map((t) => (t[1] ?? '').toLowerCase()) |
||||
) |
||||
if (seen.has(norm.toLowerCase())) { |
||||
toast.error(t('User emoji set ref duplicate')) |
||||
return |
||||
} |
||||
setSetATags((prev) => [...prev, nextTag]) |
||||
setNewSetRef('') |
||||
} |
||||
|
||||
const handleCleanList = useCallback(async () => { |
||||
if (!pubkey || cleaning) return |
||||
await checkLogin(async () => { |
||||
setCleaning(true) |
||||
try { |
||||
if (dayjs().unix() === listEvent?.created_at) { |
||||
await new Promise((resolve) => setTimeout(resolve, 1000)) |
||||
} |
||||
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({ |
||||
accountPubkey: pubkey, |
||||
favoriteRelays: favoriteRelays ?? [], |
||||
blockedRelays |
||||
}) |
||||
const preserved = preservedTagsFromUserEmojiListEvent(listEvent) |
||||
let createdAt = dayjs().unix() |
||||
if (listEvent && createdAt === listEvent.created_at) { |
||||
await new Promise((r) => setTimeout(r, 1100)) |
||||
createdAt = dayjs().unix() |
||||
} |
||||
const draft = createUserEmojiListDraftEvent(preserved, listEvent?.content ?? '', createdAt) |
||||
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays }) |
||||
setListEvent(published as Event) |
||||
await updateUserEmojiListEvent(published as Event) |
||||
setInlineEmojis([]) |
||||
setSetATags([]) |
||||
toast.success(t('List cleaned')) |
||||
} catch (e) { |
||||
toast.error(t('Failed to clean list') + ': ' + (e instanceof Error ? e.message : String(e))) |
||||
} finally { |
||||
setCleaning(false) |
||||
setCleanConfirmOpen(false) |
||||
} |
||||
}) |
||||
}, [ |
||||
pubkey, |
||||
cleaning, |
||||
listEvent, |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
publish, |
||||
updateUserEmojiListEvent, |
||||
checkLogin, |
||||
t |
||||
]) |
||||
|
||||
const openJson = useCallback(() => { |
||||
setJsonPayload({ |
||||
listEvent: listEvent ?? null, |
||||
editing: { inlineEmojis, setATags }, |
||||
note: 'Kind 10030: `emoji` tags (shortcode, URL) and `a` tags pointing at kind 30030 emoji sets.' |
||||
}) |
||||
setJsonOpen(true) |
||||
}, [listEvent, inlineEmojis, setATags]) |
||||
|
||||
if (!profile || !pubkey) { |
||||
return <NotFoundPage /> |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout |
||||
ref={ref} |
||||
index={index} |
||||
title={ |
||||
hideTitlebar |
||||
? undefined |
||||
: t('User emoji list title', { |
||||
username: profile.username, |
||||
defaultValue: `${profile.username}'s emoji list` |
||||
}) |
||||
} |
||||
hideBackButton={hideTitlebar} |
||||
controls={ |
||||
hideTitlebar ? undefined : ( |
||||
<div className="flex items-center gap-0"> |
||||
<RefreshButton onClick={() => void loadList()} /> |
||||
<DropdownMenu> |
||||
<DropdownMenuTrigger asChild> |
||||
<Button variant="ghost" size="icon" aria-label={t('More options')}> |
||||
<MoreVertical className="size-4" /> |
||||
</Button> |
||||
</DropdownMenuTrigger> |
||||
<DropdownMenuContent align="end"> |
||||
<DropdownMenuItem onClick={() => openJson()}> |
||||
<Code className="mr-2 size-4" /> |
||||
{t('View JSON')} |
||||
</DropdownMenuItem> |
||||
<DropdownMenuItem |
||||
className="text-destructive focus:text-destructive" |
||||
onClick={() => setCleanConfirmOpen(true)} |
||||
> |
||||
<Eraser className="mr-2 size-4" /> |
||||
{t('Clean list')} |
||||
</DropdownMenuItem> |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
</div> |
||||
) |
||||
} |
||||
displayScrollToTopButton |
||||
> |
||||
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> |
||||
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}> |
||||
<AlertDialogContent> |
||||
<AlertDialogHeader> |
||||
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle> |
||||
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription> |
||||
</AlertDialogHeader> |
||||
<AlertDialogFooter> |
||||
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel> |
||||
<AlertDialogAction |
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" |
||||
disabled={cleaning} |
||||
onClick={(ev) => { |
||||
ev.preventDefault() |
||||
void handleCleanList() |
||||
}} |
||||
> |
||||
{cleaning ? t('loading...') : t('Clean list')} |
||||
</AlertDialogAction> |
||||
</AlertDialogFooter> |
||||
</AlertDialogContent> |
||||
</AlertDialog> |
||||
|
||||
<div className="min-w-0 space-y-6 px-4 pb-8 pt-2"> |
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t('User emoji list intro')}</p> |
||||
|
||||
<div className="space-y-2"> |
||||
<h3 className="text-sm font-medium">{t('User emoji inline section')}</h3> |
||||
<ul className="space-y-2"> |
||||
{inlineEmojis.length === 0 ? ( |
||||
<li className="text-sm text-muted-foreground">{t('User emoji inline empty')}</li> |
||||
) : ( |
||||
inlineEmojis.map((row, i) => ( |
||||
<li |
||||
key={`${row.shortcode}-${i}`} |
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/80 bg-card px-3 py-2" |
||||
> |
||||
<div className="min-w-0"> |
||||
<code className="text-sm">:{row.shortcode}:</code> |
||||
<div className="truncate text-xs text-muted-foreground">{row.url}</div> |
||||
</div> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="shrink-0 text-destructive hover:text-destructive" |
||||
onClick={() => setInlineEmojis((prev) => prev.filter((_, j) => j !== i))} |
||||
aria-label={t('Remove')} |
||||
> |
||||
<Trash2 className="size-4" /> |
||||
</Button> |
||||
</li> |
||||
)) |
||||
)} |
||||
</ul> |
||||
<form onSubmit={addInlineEmoji} className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end"> |
||||
<div className="grid flex-1 gap-2 sm:grid-cols-2"> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="ue-short">{t('Shortcode')}</Label> |
||||
<Input |
||||
id="ue-short" |
||||
value={newShortcode} |
||||
onChange={(ev) => setNewShortcode(ev.target.value)} |
||||
placeholder="chad_yes" |
||||
autoComplete="off" |
||||
/> |
||||
</div> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="ue-url">{t('Image URL')}</Label> |
||||
<Input |
||||
id="ue-url" |
||||
value={newUrl} |
||||
onChange={(ev) => setNewUrl(ev.target.value)} |
||||
placeholder="https://…" |
||||
autoComplete="off" |
||||
/> |
||||
</div> |
||||
</div> |
||||
<Button type="submit" variant="secondary"> |
||||
{t('Add')} |
||||
</Button> |
||||
</form> |
||||
</div> |
||||
|
||||
<div className="space-y-2"> |
||||
<h3 className="text-sm font-medium">{t('User emoji sets section')}</h3> |
||||
<p className="text-xs text-muted-foreground">{t('User emoji sets hint')}</p> |
||||
<ul className="space-y-2"> |
||||
{setATags.length === 0 ? ( |
||||
<li className="text-sm text-muted-foreground">{t('User emoji sets empty')}</li> |
||||
) : ( |
||||
setATags.map((tag, i) => ( |
||||
<li |
||||
key={`${tag[1] ?? i}-${i}`} |
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/80 bg-card px-3 py-2" |
||||
> |
||||
<code className="break-all text-xs">{tag[1]}</code> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="shrink-0 text-destructive hover:text-destructive" |
||||
onClick={() => setSetATags((prev) => prev.filter((_, j) => j !== i))} |
||||
aria-label={t('Remove')} |
||||
> |
||||
<Trash2 className="size-4" /> |
||||
</Button> |
||||
</li> |
||||
)) |
||||
)} |
||||
</ul> |
||||
<form onSubmit={addSetRef} className="flex flex-col gap-2 sm:flex-row sm:items-end"> |
||||
<div className="min-w-0 flex-1 space-y-1"> |
||||
<Label htmlFor="ue-aref">{t('Emoji set coordinate')}</Label> |
||||
<Input |
||||
id="ue-aref" |
||||
value={newSetRef} |
||||
onChange={(ev) => setNewSetRef(ev.target.value)} |
||||
placeholder="30030:…" |
||||
className="font-mono text-sm" |
||||
autoComplete="off" |
||||
/> |
||||
</div> |
||||
<Button type="submit" variant="secondary"> |
||||
{t('Add')} |
||||
</Button> |
||||
</form> |
||||
</div> |
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2"> |
||||
<Button type="button" disabled={!dirty || publishing} onClick={() => void publishList()}> |
||||
{publishing ? t('loading...') : t('Publish changes')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
) |
||||
|
||||
UserEmojiListPage.displayName = 'UserEmojiListPage' |
||||
export default UserEmojiListPage |
||||
Loading…
Reference in new issue