37 changed files with 1941 additions and 140 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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