feat: emoji packs

This commit is contained in:
codytseng 2025-11-07 22:36:07 +08:00
parent 0e550d2511
commit 1e2385da3b
41 changed files with 646 additions and 59 deletions

View file

@ -91,7 +91,7 @@ function BookmarkedNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton />
return <NoteCardLoadingSkeleton className="border-b" />
}
if (!event) {

View file

@ -0,0 +1,23 @@
import { getEmojiPackInfoFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function EmojiPackPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { title, emojis } = useMemo(() => getEmojiPackInfoFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Emoji Pack')}] <span className="italic pr-0.5">{title}</span>
{emojis.length > 0 && <span>({emojis.length})</span>}
</div>
)
}

View file

@ -7,6 +7,7 @@ import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CommunityDefinitionPreview from './CommunityDefinitionPreview'
import EmojiPackPreview from './EmojiPackPreview'
import GroupMetadataPreview from './GroupMetadataPreview'
import HighlightPreview from './HighlightPreview'
import LiveEventPreview from './LiveEventPreview'
@ -100,5 +101,9 @@ export default function ContentPreview({
return <LiveEventPreview event={event} className={className} />
}
if (event.kind === kinds.Emojisets) {
return <EmojiPackPreview event={event} className={className} />
}
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
}

View file

@ -0,0 +1,86 @@
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromATag } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10
export default function EmojiPackList() {
const { t } = useTranslation()
const { userEmojiListEvent } = useNostr()
const eventIds = useMemo(() => {
if (!userEmojiListEvent) return []
return (
userEmojiListEvent.tags
.map((tag) => (tag[0] === 'a' ? generateBech32IdFromATag(tag) : null))
.filter(Boolean) as `naddr1${string}`[]
).reverse()
}, [userEmojiListEvent])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = () => {
if (showCount < eventIds.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [showCount, eventIds])
if (eventIds.length === 0) {
return (
<div className="mt-2 text-sm text-center text-muted-foreground">
{t('no emoji packs found')}
</div>
)
}
return (
<div>
{eventIds.slice(0, showCount).map((eventId) => (
<EmojiPackNote key={eventId} eventId={eventId} />
))}
</div>
)
}
function EmojiPackNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton className="border-b" />
}
if (!event) {
return null
}
return <NoteCard event={event} className="w-full" />
}

View file

@ -0,0 +1,103 @@
import { Button } from '@/components/ui/button'
import { getReplaceableCoordinateFromEvent } from '@/lib/event'
import { getEmojiPackInfoFromEvent } from '@/lib/event-metadata'
import { useEmojiPack } from '@/providers/EmojiPackProvider'
import { useNostr } from '@/providers/NostrProvider'
import { CheckIcon, Loader, PlusIcon } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import Image from '../Image'
export default function EmojiPack({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { emojiPackCoordinateSet, addEmojiPack, removeEmojiPack } = useEmojiPack()
const [updating, setUpdating] = useState(false)
const { title, emojis } = useMemo(() => getEmojiPackInfoFromEvent(event), [event])
const coordinate = useMemo(() => getReplaceableCoordinateFromEvent(event), [event])
const isCollected = useMemo(() => {
return emojiPackCoordinateSet.has(coordinate)
}, [emojiPackCoordinateSet, coordinate])
const handleCollect = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (isCollected) return
setUpdating(true)
try {
await addEmojiPack(event)
toast.success(t('Emoji pack added'))
} catch (error) {
toast.error(t('Add emoji pack failed') + ': ' + (error as Error).message)
} finally {
setUpdating(false)
}
})
}
const handleRemoveCollect = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isCollected) return
setUpdating(true)
try {
await removeEmojiPack(event)
toast.success(t('Emoji pack removed'))
} catch (error) {
toast.error(t('Remove emoji pack failed') + ': ' + (error as Error).message)
} finally {
setUpdating(false)
}
})
}
return (
<div className={className}>
<div className="flex items-center justify-between mb-2">
<h3 className="text-2xl font-semibold">{title}</h3>
{accountPubkey && (
<Button
variant={isCollected ? 'secondary' : 'outline'}
size="sm"
onClick={isCollected ? handleRemoveCollect : handleCollect}
disabled={updating}
className="shrink-0"
>
{updating ? (
<Loader className="animate-spin mr-1" />
) : isCollected ? (
<CheckIcon />
) : (
<PlusIcon />
)}
{updating
? isCollected
? t('Removing...')
: t('Adding...')
: isCollected
? t('Added')
: t('Add')}
</Button>
)}
</div>
<div className="flex flex-wrap gap-1">
{emojis.map((emoji, index) => (
<Image
key={`emoji-${index}`}
image={{ url: emoji.url, pubkey: event.pubkey }}
className="size-14 object-contain"
classNames={{
wrapper: 'size-14 flex items-center justify-center p-1',
errorPlaceholder: 'size-14'
}}
hideIfError
/>
))}
</div>
</div>
)
}

View file

@ -19,6 +19,7 @@ import TranslateButton from '../TranslateButton'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import CommunityDefinition from './CommunityDefinition'
import EmojiPack from './EmojiPack'
import GroupMetadata from './GroupMetadata'
import Highlight from './Highlight'
import IValue from './IValue'
@ -102,6 +103,8 @@ export default function Note({
content = <VideoNote className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={event} />
} else if (event.kind === kinds.Emojisets) {
content = <EmojiPack className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
}

View file

@ -1,5 +1,6 @@
import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
@ -46,9 +47,9 @@ export default function NoteCard({
return <MainNoteCard event={event} className={className} pinned={pinned} reposters={reposters} />
}
export function NoteCardLoadingSkeleton() {
export function NoteCardLoadingSkeleton({ className }: { className?: string }) {
return (
<div className="px-4 py-3">
<div className={cn('px-4 py-3', className)}>
<div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" />
<div className={`flex-1 w-0`}>

View file

@ -394,7 +394,7 @@ const NoteList = forwardRef(
) : (
<div className="flex justify-center w-full mt-2">
<Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}>
{t('reload notes')}
{t('Reload')}
</Button>
</div>
)}

View file

@ -11,7 +11,7 @@ export default function PinnedNoteCard({
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton />
return <NoteCardLoadingSkeleton className="border-b" />
}
if (!event) {

View file

@ -2,6 +2,7 @@ import AboutInfoDialog from '@/components/AboutInfoDialog'
import Donation from '@/components/Donation'
import {
toAppearanceSettings,
toEmojiPackSettings,
toGeneralSettings,
toPostSettings,
toRelaySettings,
@ -22,6 +23,7 @@ import {
PencilLine,
Server,
Settings2,
Smile,
Wallet
} from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react'
@ -84,6 +86,15 @@ export default function Settings() {
<ChevronRight />
</SettingItem>
)}
{!!pubkey && (
<SettingItem className="clickable" onClick={() => push(toEmojiPackSettings())}>
<div className="flex items-center gap-4">
<Smile />
<div>{t('Emoji Packs')}</div>
</div>
<ChevronRight />
</SettingItem>
)}
{!!nsec && (
<SettingItem
className="clickable"

View file

@ -10,10 +10,9 @@ const buttonVariants = cva(
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-primary',
ghost: 'clickable hover:text-accent-foreground',
'ghost-destructive': 'cursor-pointer hover:bg-destructive/20 text-destructive',