feat: emoji packs
This commit is contained in:
parent
0e550d2511
commit
1e2385da3b
41 changed files with 646 additions and 59 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
23
src/components/ContentPreview/EmojiPackPreview.tsx
Normal file
23
src/components/ContentPreview/EmojiPackPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
86
src/components/EmojiPackList/index.tsx
Normal file
86
src/components/EmojiPackList/index.tsx
Normal 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" />
|
||||
}
|
||||
103
src/components/Note/EmojiPack.tsx
Normal file
103
src/components/Note/EmojiPack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export default function PinnedNoteCard({
|
|||
const { event, isFetching } = useFetchEvent(eventId)
|
||||
|
||||
if (isFetching) {
|
||||
return <NoteCardLoadingSkeleton />
|
||||
return <NoteCardLoadingSkeleton className="border-b" />
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue