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

@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/sonner'
import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider'
import { FollowListProvider } from '@/providers/FollowListProvider'
@ -37,18 +38,20 @@ export default function App(): JSX.Element {
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<PinListProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</PinListProvider>
<EmojiPackProvider>
<PinListProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</PinListProvider>
</EmojiPackProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>

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',

View file

@ -92,7 +92,8 @@ export const SUPPORTED_KINDS = [
ExtendedKind.VOICE_COMMENT,
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.RELAY_REVIEW
ExtendedKind.RELAY_REVIEW,
kinds.Emojisets
]
export const URL_REGEX =

View file

@ -491,6 +491,18 @@ export default {
'Explore Relays': 'استكشف المرحلات',
'Choose a feed': 'اختر خلاصة',
'and {{x}} others': 'و {{x}} آخرون',
selfZapWarning: 'Jumble غير مسؤولة عما يحدث إذا أرسلت zap لنفسك. تابع على مسؤوليتك الخاصة. 😉⚡'
selfZapWarning:
'Jumble غير مسؤولة عما يحدث إذا أرسلت zap لنفسك. تابع على مسؤوليتك الخاصة. 😉⚡',
'Emoji Pack': 'حزمة الرموز التعبيرية',
'Emoji pack added': 'تمت إضافة حزمة الرموز التعبيرية',
'Add emoji pack failed': 'فشل إضافة حزمة الرموز التعبيرية',
'Emoji pack removed': 'تمت إزالة حزمة الرموز التعبيرية',
'Remove emoji pack failed': 'فشل إزالة حزمة الرموز التعبيرية',
Added: 'تمت الإضافة',
'Emoji Packs': 'حزم الرموز التعبيرية',
'My Packs': 'حزمي',
'Adding...': 'جاري الإضافة...',
'Removing...': 'جاري الإزالة...',
Reload: 'إعادة التحميل'
}
}

View file

@ -506,6 +506,17 @@ export default {
'Choose a feed': 'Wähle einen Feed',
'and {{x}} others': 'und {{x}} andere',
selfZapWarning:
'Jumble ist nicht verantwortlich für das, was passiert, wenn Sie sich selbst zappen. Fahren Sie auf eigene Gefahr fort. 😉⚡'
'Jumble ist nicht verantwortlich für das, was passiert, wenn Sie sich selbst zappen. Fahren Sie auf eigene Gefahr fort. 😉⚡',
'Emoji Pack': 'Emoji-Paket',
'Emoji pack added': 'Emoji-Paket hinzugefügt',
'Add emoji pack failed': 'Hinzufügen des Emoji-Pakets fehlgeschlagen',
'Emoji pack removed': 'Emoji-Paket entfernt',
'Remove emoji pack failed': 'Entfernen des Emoji-Pakets fehlgeschlagen',
Added: 'Hinzugefügt',
'Emoji Packs': 'Emoji-Pakete',
'My Packs': 'Meine Pakete',
'Adding...': 'Wird hinzugefügt...',
'Removing...': 'Wird entfernt...',
Reload: 'Neu laden'
}
}

View file

@ -491,6 +491,17 @@ export default {
'Choose a feed': 'Choose a feed',
'and {{x}} others': 'and {{x}} others',
selfZapWarning:
'Jumble is not responsible for what happens if you zap yourself. Proceed at your own risk. 😉⚡'
'Jumble is not responsible for what happens if you zap yourself. Proceed at your own risk. 😉⚡',
'Emoji Pack': 'Emoji Pack',
'Emoji pack added': 'Emoji pack added',
'Add emoji pack failed': 'Add emoji pack failed',
'Emoji pack removed': 'Emoji pack removed',
'Remove emoji pack failed': 'Remove emoji pack failed',
Added: 'Added',
'Emoji Packs': 'Emoji Packs',
'My Packs': 'My Packs',
'Adding...': 'Adding...',
'Removing...': 'Removing...',
Reload: 'Reload'
}
}

View file

@ -500,6 +500,17 @@ export default {
'Choose a feed': 'Elige un feed',
'and {{x}} others': 'y {{x}} otros',
selfZapWarning:
'Jumble no se hace responsable de lo que suceda si te zapeas a ti mismo. Procede bajo tu propio riesgo. 😉⚡'
'Jumble no se hace responsable de lo que suceda si te zapeas a ti mismo. Procede bajo tu propio riesgo. 😉⚡',
'Emoji Pack': 'Paquete de Emojis',
'Emoji pack added': 'Paquete de emojis añadido',
'Add emoji pack failed': 'Error al añadir paquete de emojis',
'Emoji pack removed': 'Paquete de emojis eliminado',
'Remove emoji pack failed': 'Error al eliminar paquete de emojis',
Added: 'Añadido',
'Emoji Packs': 'Paquetes de Emojis',
'My Packs': 'Mis Paquetes',
'Adding...': 'Añadiendo...',
'Removing...': 'Eliminando...',
Reload: 'Recargar'
}
}

View file

@ -495,6 +495,17 @@ export default {
'Choose a feed': 'یک فید انتخاب کنید',
'and {{x}} others': 'و {{x}} دیگر',
selfZapWarning:
'Jumble مسئولیتی در قبال اتفاقاتی که در صورت ارسال zap به خودتان می‌افتد ندارد. با مسئولیت خود ادامه دهید. 😉⚡'
'Jumble مسئولیتی در قبال اتفاقاتی که در صورت ارسال zap به خودتان می‌افتد ندارد. با مسئولیت خود ادامه دهید. 😉⚡',
'Emoji Pack': 'بسته ایموجی',
'Emoji pack added': 'بسته ایموجی اضافه شد',
'Add emoji pack failed': 'افزودن بسته ایموجی ناموفق بود',
'Emoji pack removed': 'بسته ایموجی حذف شد',
'Remove emoji pack failed': 'حذف بسته ایموجی ناموفق بود',
Added: 'اضافه شد',
'Emoji Packs': 'بسته‌های ایموجی',
'My Packs': 'بسته‌های من',
'Adding...': 'در حال افزودن...',
'Removing...': 'در حال حذف...',
Reload: 'بازخوانی'
}
}

View file

@ -505,6 +505,17 @@ export default {
'Choose a feed': 'Choisir un fil',
'and {{x}} others': 'et {{x}} autres',
selfZapWarning:
"Jumble n'est pas responsable de ce qui se passe si vous vous zappez vous-même. Procédez à vos risques et périls. 😉⚡"
"Jumble n'est pas responsable de ce qui se passe si vous vous zappez vous-même. Procédez à vos risques et périls. 😉⚡",
'Emoji Pack': "Pack d'Emojis",
'Emoji pack added': "Pack d'emojis ajouté",
'Add emoji pack failed': "Échec de l'ajout du pack d'emojis",
'Emoji pack removed': "Pack d'emojis supprimé",
'Remove emoji pack failed': "Échec de la suppression du pack d'emojis",
Added: 'Ajouté',
'Emoji Packs': "Packs d'Emojis",
'My Packs': 'Mes Packs',
'Adding...': 'Ajout...',
'Removing...': 'Suppression...',
Reload: 'Recharger'
}
}

View file

@ -497,6 +497,17 @@ export default {
'Choose a feed': 'एक फीड चुनें',
'and {{x}} others': 'और {{x}} अन्य',
selfZapWarning:
'Jumble आपके द्वारा स्वयं को zap करने पर क्या होता है, इसके लिए जिम्मेदार नहीं है। अपनी जोखिम पर आगे बढ़ें। 😉⚡'
'Jumble आपके द्वारा स्वयं को zap करने पर क्या होता है, इसके लिए जिम्मेदार नहीं है। अपनी जोखिम पर आगे बढ़ें। 😉⚡',
'Emoji Pack': 'इमोजी पैक',
'Emoji pack added': 'इमोजी पैक जोड़ा गया',
'Add emoji pack failed': 'इमोजी पैक जोड़ना विफल रहा',
'Emoji pack removed': 'इमोजी पैक हटाया गया',
'Remove emoji pack failed': 'इमोजी पैक हटाना विफल रहा',
Added: 'जोड़ा गया',
'Emoji Packs': 'इमोजी पैक',
'My Packs': 'मेरे पैक',
'Adding...': 'जोड़ा जा रहा है...',
'Removing...': 'हटाया जा रहा है...',
Reload: 'रीलोड करें'
}
}

View file

@ -492,6 +492,17 @@ export default {
'Jumble egy kliens, amivel könnyen böngészhetsz csomópontokat. Kezdd az érdekes csomópontok felderítésével, vagy lépj be, hogy a követettek posztjait megnézd.',
'Explore Relays': 'Csomópontok felderítése',
'Choose a feed': 'Válassz hírfolyamot',
'and {{x}} others': 'és {{x}} másik'
'and {{x}} others': 'és {{x}} másik',
'Emoji Pack': 'Emoji csomag',
'Emoji pack added': 'Emoji csomag hozzáadva',
'Add emoji pack failed': 'Emoji csomag hozzáadása sikertelen',
'Emoji pack removed': 'Emoji csomag eltávolítva',
'Remove emoji pack failed': 'Emoji csomag eltávolítása sikertelen',
Added: 'Hozzáadva',
'Emoji Packs': 'Emoji csomagok',
'My Packs': 'Saját csomagjaim',
'Adding...': 'Hozzáadás...',
'Removing...': 'Eltávolítás...',
Reload: 'Újratöltés'
}
}

View file

@ -500,6 +500,17 @@ export default {
'Choose a feed': 'Scegli un feed',
'and {{x}} others': 'e altri {{x}}',
selfZapWarning:
'Jumble non è responsabile di ciò che accade se zappi te stesso. Procedi a tuo rischio e pericolo. 😉⚡'
'Jumble non è responsabile di ciò che accade se zappi te stesso. Procedi a tuo rischio e pericolo. 😉⚡',
'Emoji Pack': 'Pacchetto Emoji',
'Emoji pack added': 'Pacchetto emoji aggiunto',
'Add emoji pack failed': 'Aggiunta del pacchetto emoji non riuscita',
'Emoji pack removed': 'Pacchetto emoji rimosso',
'Remove emoji pack failed': 'Rimozione del pacchetto emoji non riuscita',
Added: 'Aggiunto',
'Emoji Packs': 'Pacchetti Emoji',
'My Packs': 'I Miei Pacchetti',
'Adding...': 'Aggiunta...',
'Removing...': 'Rimozione...',
Reload: 'Ricarica'
}
}

View file

@ -496,6 +496,17 @@ export default {
'Choose a feed': 'フィードを選択',
'and {{x}} others': 'および他{{x}}人',
selfZapWarning:
'Jumble は、あなたが自分自身にザップした場合の結果について責任を負いません。自己責任で続行してください。😉⚡'
'Jumble は、あなたが自分自身にザップした場合の結果について責任を負いません。自己責任で続行してください。😉⚡',
'Emoji Pack': '絵文字パック',
'Emoji pack added': '絵文字パックを追加しました',
'Add emoji pack failed': '絵文字パックの追加に失敗しました',
'Emoji pack removed': '絵文字パックを削除しました',
'Remove emoji pack failed': '絵文字パックの削除に失敗しました',
Added: '追加済み',
'Emoji Packs': '絵文字パック',
'My Packs': 'マイパック',
'Adding...': '追加中...',
'Removing...': '削除中...',
Reload: '再読み込み'
}
}

View file

@ -496,6 +496,17 @@ export default {
'Choose a feed': '피드 선택',
'and {{x}} others': '및 기타 {{x}}명',
selfZapWarning:
'Jumble은 자신에게 Zap을 보낼 때 발생하는 일에 대해 책임을 지지 않습니다. 본인의 책임 하에 진행하세요. 😉⚡'
'Jumble은 자신에게 Zap을 보낼 때 발생하는 일에 대해 책임을 지지 않습니다. 본인의 책임 하에 진행하세요. 😉⚡',
'Emoji Pack': '이모지 팩',
'Emoji pack added': '이모지 팩이 추가되었습니다',
'Add emoji pack failed': '이모지 팩 추가 실패',
'Emoji pack removed': '이모지 팩이 제거되었습니다',
'Remove emoji pack failed': '이모지 팩 제거 실패',
Added: '추가됨',
'Emoji Packs': '이모지 팩',
'My Packs': '내 팩',
'Adding...': '추가 중...',
'Removing...': '제거 중...',
Reload: '다시 불러오기'
}
}

View file

@ -500,6 +500,17 @@ export default {
'Choose a feed': 'Wybierz feed',
'and {{x}} others': 'i {{x}} innych',
selfZapWarning:
'Jumble nie ponosi odpowiedzialności za to, co się stanie, jeśli zappujesz samego siebie. Kontynuuj na własne ryzyko. 😉⚡'
'Jumble nie ponosi odpowiedzialności za to, co się stanie, jeśli zappujesz samego siebie. Kontynuuj na własne ryzyko. 😉⚡',
'Emoji Pack': 'Pakiet Emoji',
'Emoji pack added': 'Pakiet emoji dodany',
'Add emoji pack failed': 'Dodawanie pakietu emoji nie powiodło się',
'Emoji pack removed': 'Pakiet emoji usunięty',
'Remove emoji pack failed': 'Usuwanie pakietu emoji nie powiodło się',
Added: 'Dodano',
'Emoji Packs': 'Pakiety Emoji',
'My Packs': 'Moje Pakiety',
'Adding...': 'Dodawanie...',
'Removing...': 'Usuwanie...',
Reload: 'Przeładuj'
}
}

View file

@ -497,6 +497,17 @@ export default {
'Choose a feed': 'Escolha um feed',
'and {{x}} others': 'e {{x}} outros',
selfZapWarning:
'Jumble não é responsável pelo que acontece se você zap a si mesmo. Prossiga por sua conta e risco. 😉⚡'
'Jumble não é responsável pelo que acontece se você zap a si mesmo. Prossiga por sua conta e risco. 😉⚡',
'Emoji Pack': 'Pacote de Emojis',
'Emoji pack added': 'Pacote de emojis adicionado',
'Add emoji pack failed': 'Falha ao adicionar pacote de emojis',
'Emoji pack removed': 'Pacote de emojis removido',
'Remove emoji pack failed': 'Falha ao remover pacote de emojis',
Added: 'Adicionado',
'Emoji Packs': 'Pacotes de Emojis',
'My Packs': 'Meus Pacotes',
'Adding...': 'Adicionando...',
'Removing...': 'Removendo...',
Reload: 'Recarregar'
}
}

View file

@ -500,6 +500,17 @@ export default {
'Choose a feed': 'Escolha um feed',
'and {{x}} others': 'e {{x}} outros',
selfZapWarning:
'Jumble não é responsável pelo que acontece se você zap a si mesmo. Prossiga por sua conta e risco. 😉⚡'
'Jumble não é responsável pelo que acontece se você zap a si mesmo. Prossiga por sua conta e risco. 😉⚡',
'Emoji Pack': 'Pacote de Emojis',
'Emoji pack added': 'Pacote de emojis adicionado',
'Add emoji pack failed': 'Falha ao adicionar pacote de emojis',
'Emoji pack removed': 'Pacote de emojis removido',
'Remove emoji pack failed': 'Falha ao remover pacote de emojis',
Added: 'Adicionado',
'Emoji Packs': 'Pacotes de Emojis',
'My Packs': 'Os Meus Pacotes',
'Adding...': 'A adicionar...',
'Removing...': 'A remover...',
Reload: 'Recarregar'
}
}

View file

@ -502,6 +502,17 @@ export default {
'Choose a feed': 'Выберите ленту',
'and {{x}} others': 'и {{x}} других',
selfZapWarning:
'Jumble не несет ответственности за то, что произойдет, если вы отправите zap самому себе. Продолжайте на свой страх и риск. 😉⚡'
'Jumble не несет ответственности за то, что произойдет, если вы отправите zap самому себе. Продолжайте на свой страх и риск. 😉⚡',
'Emoji Pack': 'Набор эмодзи',
'Emoji pack added': 'Набор эмодзи добавлен',
'Add emoji pack failed': 'Не удалось добавить набор эмодзи',
'Emoji pack removed': 'Набор эмодзи удален',
'Remove emoji pack failed': 'Не удалось удалить набор эмодзи',
Added: 'Добавлено',
'Emoji Packs': 'Наборы эмодзи',
'My Packs': 'Мои наборы',
'Adding...': 'Добавление...',
'Removing...': 'Удаление...',
Reload: 'Перезагрузить'
}
}

View file

@ -490,6 +490,17 @@ export default {
'Choose a feed': 'เลือกฟีด',
'and {{x}} others': 'และอื่น ๆ {{x}} รายการ',
selfZapWarning:
'Jumble ไม่รับผิดชอบต่อสิ่งที่เกิดขึ้นหากคุณ zap ตัวเอง ดำเนินการด้วยความเสี่ยงของคุณเอง 😉⚡'
'Jumble ไม่รับผิดชอบต่อสิ่งที่เกิดขึ้นหากคุณ zap ตัวเอง ดำเนินการด้วยความเสี่ยงของคุณเอง 😉⚡',
'Emoji Pack': 'แพ็คอีโมจิ',
'Emoji pack added': 'เพิ่มแพ็คอีโมจิแล้ว',
'Add emoji pack failed': 'การเพิ่มแพ็คอีโมจิล้มเหลว',
'Emoji pack removed': 'ลบแพ็คอีโมจิแล้ว',
'Remove emoji pack failed': 'การลบแพ็คอีโมจิล้มเหลว',
Added: 'เพิ่มแล้ว',
'Emoji Packs': 'แพ็คอีโมจิ',
'My Packs': 'แพ็คของฉัน',
'Adding...': 'กำลังเพิ่ม...',
'Removing...': 'กำลังลบ...',
Reload: 'โหลดใหม่'
}
}

View file

@ -487,6 +487,17 @@ export default {
'Explore Relays': '探索服务器',
'Choose a feed': '选择一个动态',
'and {{x}} others': '和其他 {{x}} 人',
selfZapWarning: 'Jumble 对您给自己打赏所发生的事情概不负责。风险自负。😉⚡'
selfZapWarning: 'Jumble 对您给自己打赏所发生的事情概不负责。风险自负。😉⚡',
'Emoji Pack': '表情包',
'Emoji pack added': '表情包已添加',
'Add emoji pack failed': '添加表情包失败',
'Emoji pack removed': '表情包已移除',
'Remove emoji pack failed': '移除表情包失败',
Added: '已添加',
'Emoji Packs': '表情包',
'My Packs': '我的表情包',
'Adding...': '添加中...',
'Removing...': '移除中...',
Reload: '重新加载'
}
}

View file

@ -341,6 +341,15 @@ export function createPinListDraftEvent(tags: string[][], content = ''): TDraftE
}
}
export function createUserEmojiListDraftEvent(tags: string[][], content = ''): TDraftEvent {
return {
kind: kinds.UserEmojiList,
content,
tags,
created_at: dayjs().unix()
}
}
export function createBlossomServerListDraftEvent(servers: string[]): TDraftEvent {
return {
kind: ExtendedKind.BLOSSOM_SERVER_LIST,

View file

@ -355,11 +355,14 @@ export function getEmojisAndEmojiSetsFromEvent(event: Event) {
return { emojis, emojiSetPointers }
}
export function getEmojisFromEvent(event: Event): TEmoji[] {
export function getEmojiPackInfoFromEvent(event: Event) {
let title: string | undefined
const emojis: TEmoji[] = []
event.tags.forEach(([tagName, ...tagValues]) => {
if (tagName === 'emoji' && tagValues.length >= 2) {
if (tagName === 'title' && tagValues[0]) {
title = tagValues[0]
} else if (tagName === 'emoji' && tagValues.length >= 2) {
emojis.push({
shortcode: tagValues[0],
url: tagValues[1]
@ -367,7 +370,12 @@ export function getEmojisFromEvent(event: Event): TEmoji[] {
}
})
return emojis
return { title, emojis }
}
export function getEmojisFromEvent(event: Event): TEmoji[] {
const info = getEmojiPackInfoFromEvent(event)
return info.emojis
}
export function getStarsFromRelayReviewEvent(event: Event): number {

View file

@ -71,6 +71,7 @@ export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general'
export const toAppearanceSettings = () => '/settings/appearance'
export const toTranslation = () => '/settings/translation'
export const toEmojiPackSettings = () => '/settings/emoji-packs'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`

View file

@ -0,0 +1,43 @@
import EmojiPackList from '@/components/EmojiPackList'
import NoteList from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import { BIG_RELAY_URLS } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { kinds } from 'nostr-tools'
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type TTab = 'my-packs' | 'explore'
const EmojiPackSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { hideUntrustedNotes } = useUserTrust()
const [tab, setTab] = useState<TTab>('my-packs')
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Emoji Packs')} displayScrollToTopButton>
<Tabs
value={tab}
tabs={[
{ value: 'my-packs', label: 'My Packs' },
{ value: 'explore', label: 'Explore' }
]}
onTabChange={(tab) => {
setTab(tab as TTab)
}}
/>
{tab === 'my-packs' ? (
<EmojiPackList />
) : (
<NoteList
showKinds={[kinds.Emojisets]}
subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]}
hideUntrustedNotes={hideUntrustedNotes}
/>
)}
</SecondaryPageLayout>
)
})
EmojiPackSettingsPage.displayName = 'EmojiPackSettingsPage'
export default EmojiPackSettingsPage

View file

@ -9,7 +9,6 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { TMediaAutoLoadPolicy } from '@/types'
import { SelectValue } from '@radix-ui/react-select'
import { ExternalLink } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -109,22 +108,6 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</Label>
<Switch id="show-nsfw" checked={defaultShowNsfw} onCheckedChange={setDefaultShowNsfw} />
</SettingItem>
<SettingItem>
<div>
<a
className="flex items-center gap-1 cursor-pointer hover:underline"
href="https://emojito.meme/browse"
target="_blank"
rel="noopener noreferrer"
>
{t('Custom emoji management')}
<ExternalLink />
</a>
<div className="text-muted-foreground">
{t('After changing emojis, you may need to refresh the page')}
</div>
</div>
</SettingItem>
</div>
</SecondaryPageLayout>
)

View file

@ -0,0 +1,90 @@
import { buildATag, createUserEmojiListDraftEvent } from '@/lib/draft-event'
import { getReplaceableCoordinateFromEvent } from '@/lib/event'
import client from '@/services/client.service'
import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useMemo } from 'react'
import { useNostr } from './NostrProvider'
type TEmojiPackContext = {
emojiPackCoordinateSet: Set<string>
addEmojiPack: (event: Event) => Promise<void>
removeEmojiPack: (event: Event) => Promise<void>
}
const EmojiPackContext = createContext<TEmojiPackContext | undefined>(undefined)
export const useEmojiPack = () => {
const context = useContext(EmojiPackContext)
if (!context) {
throw new Error('useEmojiPack must be used within a EmojiPackProvider')
}
return context
}
export function EmojiPackProvider({ children }: { children: React.ReactNode }) {
const {
pubkey: accountPubkey,
userEmojiListEvent,
publish,
updateUserEmojiListEvent
} = useNostr()
const emojiPackCoordinateSet = useMemo(() => {
const set = new Set<string>()
userEmojiListEvent?.tags.forEach((tag) => {
if (tag[0] === 'a') {
set.add(tag[1])
}
})
return set
}, [userEmojiListEvent])
const addEmojiPack = async (event: Event) => {
if (!accountPubkey || event.kind !== kinds.Emojisets) return
const userEmojiListEvent = await client.fetchUserEmojiListEvent(accountPubkey)
const currentTags = userEmojiListEvent?.tags || []
const coordinate = getReplaceableCoordinateFromEvent(event)
// Check if already exists
if (currentTags.some((tag) => tag[0] === 'a' && tag[1] === coordinate)) {
return
}
const newUserEmojiListDraftEvent = createUserEmojiListDraftEvent(
[...currentTags, buildATag(event)],
userEmojiListEvent?.content
)
const newUserEmojiListEvent = await publish(newUserEmojiListDraftEvent)
await updateUserEmojiListEvent(newUserEmojiListEvent)
}
const removeEmojiPack = async (event: Event) => {
if (!accountPubkey) return
const userEmojiListEvent = await client.fetchUserEmojiListEvent(accountPubkey)
if (!userEmojiListEvent) return
const coordinate = getReplaceableCoordinateFromEvent(event)
const newTags = userEmojiListEvent.tags.filter((tag) => tag[0] !== 'a' || tag[1] !== coordinate)
if (newTags.length === userEmojiListEvent.tags.length) return
const newUserEmojiListDraftEvent = createUserEmojiListDraftEvent(
newTags,
userEmojiListEvent.content
)
const newUserEmojiListEvent = await publish(newUserEmojiListDraftEvent)
await updateUserEmojiListEvent(newUserEmojiListEvent)
}
return (
<EmojiPackContext.Provider
value={{
emojiPackCoordinateSet,
addEmojiPack,
removeEmojiPack
}}
>
{children}
</EmojiPackContext.Provider>
)
}

View file

@ -86,6 +86,7 @@ type TNostrContext = {
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateUserEmojiListEvent: (userEmojiListEvent: Event) => Promise<void>
updatePinListEvent: (pinListEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
}
@ -743,6 +744,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const updateUserEmojiListEvent = async (userEmojiListEvent: Event) => {
const newUserEmojiListEvent = await indexedDb.putReplaceableEvent(userEmojiListEvent)
if (newUserEmojiListEvent.id !== userEmojiListEvent.id) return
setUserEmojiListEvent(newUserEmojiListEvent)
}
const updatePinListEvent = async (pinListEvent: Event) => {
const newPinListEvent = await indexedDb.putReplaceableEvent(pinListEvent)
if (newPinListEvent.id !== pinListEvent.id) return
@ -813,6 +821,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateMuteListEvent,
updateBookmarkListEvent,
updateFavoriteRelaysEvent,
updateUserEmojiListEvent,
updatePinListEvent,
updateNotificationsSeenAt
}}

View file

@ -1,5 +1,6 @@
import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage'
import BookmarkPage from '@/pages/secondary/BookmarkPage'
import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage'
import FollowingListPage from '@/pages/secondary/FollowingListPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import MuteListPage from '@/pages/secondary/MuteListPage'
@ -39,6 +40,7 @@ const SECONDARY_ROUTE_CONFIGS = [
{ path: '/settings/general', element: <GeneralSettingsPage /> },
{ path: '/settings/appearance', element: <AppearanceSettingsPage /> },
{ path: '/settings/translation', element: <TranslationPage /> },
{ path: '/settings/emoji-packs', element: <EmojiPackSettingsPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/mutes', element: <MuteListPage /> },
{ path: '/rizful', element: <RizfulPage /> },

View file

@ -759,6 +759,11 @@ class ClientService extends EventTarget {
if (cache) {
return cache
}
const indexedDbCache = await indexedDb.getReplaceableEventByCoordinate(coordinate)
if (indexedDbCache) {
this.replaceableEventCacheMap.set(coordinate, indexedDbCache)
return indexedDbCache
}
} else if (eventId) {
const cache = this.eventCacheMap.get(eventId)
if (cache) {
@ -1356,6 +1361,10 @@ class ClientService extends EventTarget {
return this.fetchReplaceableEvent(pubkey, kinds.Pinlist)
}
async fetchUserEmojiListEvent(pubkey: string) {
return this.fetchReplaceableEvent(pubkey, kinds.UserEmojiList)
}
async updateBlossomServerListEventCache(evt: NEvent) {
await this.updateReplaceableEventCache(evt)
}

View file

@ -189,6 +189,12 @@ class IndexedDbService {
})
}
async getReplaceableEventByCoordinate(coordinate: string): Promise<Event | undefined | null> {
const [kind, pubkey, ...rest] = coordinate.split(':')
const d = rest.length > 0 ? rest.join(':') : undefined
return this.getReplaceableEvent(pubkey, parseInt(kind), d)
}
async getReplaceableEvent(
pubkey: string,
kind: number,