feat: custom emoji

This commit is contained in:
codytseng
2025-08-22 21:05:44 +08:00
parent 481d6a1447
commit 71d4420604
46 changed files with 885 additions and 176 deletions

55
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toast": "^1.2.4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-emoji": "^2.26.1",
"@tiptap/extension-history": "^2.12.0", "@tiptap/extension-history": "^2.12.0",
"@tiptap/extension-mention": "^2.12.0", "@tiptap/extension-mention": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0", "@tiptap/extension-placeholder": "^2.12.0",
@@ -4343,6 +4344,30 @@
"@tiptap/pm": "^2.7.0" "@tiptap/pm": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-emoji": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-emoji/-/extension-emoji-2.26.1.tgz",
"integrity": "sha512-CtK10GF80Qr4lgJ7P6W6tVThOjpq1lh8oyoBospZ+CjD4GYcY73bdl+FP0uxhZdJsMHzaqzMP5wWQ54zHsIaIg==",
"dependencies": {
"emoji-regex": "^10.4.0",
"emojibase-data": "^15",
"is-emoji-supported": "^0.0.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"@tiptap/suggestion": "^2.7.0"
}
},
"node_modules/@tiptap/extension-emoji/node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="
},
"node_modules/@tiptap/extension-floating-menu": { "node_modules/@tiptap/extension-floating-menu": {
"version": "2.12.0", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.12.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.12.0.tgz",
@@ -6387,6 +6412,31 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
}, },
"node_modules/emojibase": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/emojibase/-/emojibase-16.0.0.tgz",
"integrity": "sha512-Nw2m7JLIO4Ou2X/yZPRNscHQXVbbr6SErjkJ7EooG7MbR3yDZszCv9KTizsXFc7yZl0n3WF+qUKIC/Lw6H9xaQ==",
"peer": true,
"engines": {
"node": ">=18.12.0"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
}
},
"node_modules/emojibase-data": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-15.3.2.tgz",
"integrity": "sha512-TpDyTDDTdqWIJixV5sTA6OQ0P0JfIIeK2tFRR3q56G9LK65ylAZ7z3KyBXokpvTTJ+mLUXQXbLNyVkjvnTLE+A==",
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/milesjohnson"
},
"peerDependencies": {
"emojibase": "*"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -7649,6 +7699,11 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/is-emoji-supported": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz",
"integrity": "sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw=="
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",

View File

@@ -41,6 +41,7 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toast": "^1.2.4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-emoji": "^2.26.1",
"@tiptap/extension-history": "^2.12.0", "@tiptap/extension-history": "^2.12.0",
"@tiptap/extension-mention": "^2.12.0", "@tiptap/extension-mention": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0", "@tiptap/extension-placeholder": "^2.12.0",

View File

@@ -130,7 +130,7 @@ const Content = memo(
const shortcode = node.data.split(':')[1] const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode) const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data if (!emoji) return node.data
return <Emoji emoji={emoji} key={index} /> return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
} }
if (node.type === 'youtube') { if (node.type === 'youtube') {
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" /> return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />

View File

@@ -55,7 +55,7 @@ export default function Content({
const shortcode = node.data.split(':')[1] const shortcode = node.data.split(':')[1]
const emoji = emojiInfos?.find((e) => e.shortcode === shortcode) const emoji = emojiInfos?.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} /> return <Emoji key={index} emoji={emoji} classNames={{ img: 'mb-1' }} />
} }
})} })}
</span> </span>

View File

@@ -17,7 +17,7 @@ export default function Emoji({
if (typeof emoji === 'string') { if (typeof emoji === 'string') {
return emoji === '+' ? ( return emoji === '+' ? (
<Heart className={cn('size-4 text-red-400 fill-red-400', classNames?.img)} /> <Heart className={cn('size-5 text-red-400 fill-red-400', classNames?.img)} />
) : ( ) : (
<span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span> <span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
) )
@@ -33,7 +33,7 @@ export default function Emoji({
<img <img
src={emoji.url} src={emoji.url}
alt={emoji.shortcode} alt={emoji.shortcode}
className={cn('inline-block size-4', classNames?.img)} className={cn('inline-block size-5 rounded-sm', classNames?.img)}
onLoad={() => { onLoad={() => {
setHasError(false) setHasError(false)
}} }}

View File

@@ -1,14 +1,20 @@
import { parseEmojiPickerUnified } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTheme } from '@/providers/ThemeProvider' import { useTheme } from '@/providers/ThemeProvider'
import customEmojiService from '@/services/custom-emoji.service'
import { TEmoji } from '@/types'
import EmojiPickerReact, { import EmojiPickerReact, {
EmojiStyle, EmojiStyle,
SkinTonePickerLocation, SkinTonePickerLocation,
SuggestionMode, SuggestionMode,
Theme Theme
} from 'emoji-picker-react' } from 'emoji-picker-react'
import { MouseDownEvent } from 'emoji-picker-react/dist/config/config'
export default function EmojiPicker({ onEmojiClick }: { onEmojiClick: MouseDownEvent }) { export default function EmojiPicker({
onEmojiClick
}: {
onEmojiClick: (emoji: string | TEmoji | undefined, event: MouseEvent) => void
}) {
const { themeSetting } = useTheme() const { themeSetting } = useTheme()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@@ -31,7 +37,11 @@ export default function EmojiPicker({ onEmojiClick }: { onEmojiClick: MouseDownE
} as React.CSSProperties } as React.CSSProperties
} }
suggestedEmojisMode={SuggestionMode.FREQUENT} suggestedEmojisMode={SuggestionMode.FREQUENT}
onEmojiClick={onEmojiClick} onEmojiClick={(data, e) => {
const emoji = parseEmojiPickerUnified(data.unified)
onEmojiClick(emoji, e)
}}
customEmojis={customEmojiService.getAllCustomEmojisForPicker()}
/> />
) )
} }

View File

@@ -5,6 +5,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TEmoji } from '@/types'
import { useState } from 'react' import { useState } from 'react'
import EmojiPicker from '../EmojiPicker' import EmojiPicker from '../EmojiPicker'
@@ -13,7 +14,7 @@ export default function EmojiPickerDialog({
onEmojiClick onEmojiClick
}: { }: {
children: React.ReactNode children: React.ReactNode
onEmojiClick?: (emoji: string) => void onEmojiClick?: (emoji: string | TEmoji | undefined) => void
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -24,10 +25,10 @@ export default function EmojiPickerDialog({
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent> <DrawerContent>
<EmojiPicker <EmojiPicker
onEmojiClick={(data, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()
setOpen(false) setOpen(false)
onEmojiClick?.(data.emoji) onEmojiClick?.(emoji)
}} }}
/> />
</DrawerContent> </DrawerContent>
@@ -40,10 +41,10 @@ export default function EmojiPickerDialog({
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit"> <DropdownMenuContent side="top" className="p-0 w-fit">
<EmojiPicker <EmojiPicker
onEmojiClick={(data, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()
setOpen(false) setOpen(false)
onEmojiClick?.(data.emoji) onEmojiClick?.(emoji)
}} }}
/> />
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -10,6 +10,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { TEmoji } from '@/types'
import { Loader, SmilePlus } from 'lucide-react' import { Loader, SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@@ -37,7 +38,7 @@ export default function LikeButton({ event }: { event: Event }) {
return { myLastEmoji: myLike?.emoji, likeCount: likes?.length } return { myLastEmoji: myLike?.emoji, likeCount: likes?.length }
}, [noteStats, pubkey, hideUntrustedInteractions]) }, [noteStats, pubkey, hideUntrustedInteractions])
const like = async (emoji: string) => { const like = async (emoji: string | TEmoji) => {
checkLogin(async () => { checkLogin(async () => {
if (liking || !pubkey) return if (liking || !pubkey) return
@@ -75,9 +76,7 @@ export default function LikeButton({ event }: { event: Event }) {
<Loader className="animate-spin" /> <Loader className="animate-spin" />
) : myLastEmoji ? ( ) : myLastEmoji ? (
<> <>
<div className="h-5 w-5 flex items-center justify-center"> <Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
<Emoji emoji={myLastEmoji} />
</div>
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>} {!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</> </>
) : ( ) : (
@@ -97,9 +96,11 @@ export default function LikeButton({ event }: { event: Event }) {
<DrawerOverlay onClick={() => setIsEmojiReactionsOpen(false)} /> <DrawerOverlay onClick={() => setIsEmojiReactionsOpen(false)} />
<DrawerContent hideOverlay> <DrawerContent hideOverlay>
<EmojiPicker <EmojiPicker
onEmojiClick={(data) => { onEmojiClick={(emoji) => {
setIsEmojiReactionsOpen(false) setIsEmojiReactionsOpen(false)
like(data.emoji) if (!emoji) return
like(emoji)
}} }}
/> />
</DrawerContent> </DrawerContent>
@@ -122,10 +123,12 @@ export default function LikeButton({ event }: { event: Event }) {
<DropdownMenuContent side="top" className="p-0 w-fit"> <DropdownMenuContent side="top" className="p-0 w-fit">
{isPickerOpen ? ( {isPickerOpen ? (
<EmojiPicker <EmojiPicker
onEmojiClick={(data, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()
setIsEmojiReactionsOpen(false) setIsEmojiReactionsOpen(false)
like(data.emoji) if (!emoji) return
like(emoji)
}} }}
/> />
) : ( ) : (

View File

@@ -71,7 +71,11 @@ export default function Likes({ event }: { event: Event }) {
like(key, emoji) like(key, emoji)
}} }}
> >
{liking === key ? <Loader className="animate-spin size-4" /> : <Emoji emoji={emoji} />} {liking === key ? (
<Loader className="animate-spin size-4" />
) : (
<Emoji emoji={emoji} classNames={{ img: 'size-4' }} />
)}
<div className="text-sm">{pubkeys.size}</div> <div className="text-sm">{pubkeys.size}</div>
</div> </div>
))} ))}

View File

@@ -254,7 +254,12 @@ export default function PostContent({
opening the emoji picker drawer causes an issue, opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */} the emoji I tap isn't the one that gets inserted. */}
{!isTouchDevice() && ( {!isTouchDevice() && (
<EmojiPickerDialog onEmojiClick={(emoji) => textareaRef.current?.insertText(emoji)}> <EmojiPickerDialog
onEmojiClick={(emoji) => {
if (!emoji) return
textareaRef.current?.insertEmoji(emoji)
}}
>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<Smile /> <Smile />
</Button> </Button>

View File

@@ -0,0 +1,131 @@
import Emoji from '@/components/Emoji'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
export interface EmojiListProps {
items: string[]
command: (params: { name?: string }) => void
}
export interface EmojiListHandler {
onKeyDown: (params: { event: KeyboardEvent }) => boolean
}
export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = (index: number): void => {
const item = props.items[index]
if (item) {
props.command({ name: item })
}
customEmojiService.updateSuggested(item)
}
const upHandler = (): void => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
}
const downHandler = (): void => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = (): void => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => {
return {
onKeyDown: (x: { event: KeyboardEvent }): boolean => {
if (x.event.key === 'ArrowUp') {
upHandler()
return true
}
if (x.event.key === 'ArrowDown') {
downHandler()
return true
}
if (x.event.key === 'Enter') {
enterHandler()
return true
}
return false
}
}
}, [upHandler, downHandler, enterHandler])
if (!props.items?.length) {
return null
}
return (
<ScrollArea
className="border rounded-lg bg-background z-50 pointer-events-auto flex flex-col max-h-80 overflow-y-auto"
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<div className="p-1">
{props.items.map((item, index) => {
return (
<EmojiListItem
key={item}
id={item}
selectedIndex={selectedIndex}
index={index}
selectItem={selectItem}
setSelectedIndex={setSelectedIndex}
/>
)
})}
</div>
</ScrollArea>
)
})
function EmojiListItem({
id,
selectedIndex,
index,
selectItem,
setSelectedIndex
}: {
id: string
selectedIndex: number
index: number
selectItem: (index: number) => void
setSelectedIndex: (index: number) => void
}) {
const emoji = useMemo(() => customEmojiService.getEmojiById(id), [id])
if (!emoji) return null
return (
<button
className={cn(
'cursor-pointer w-full p-1 rounded-lg transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
selectedIndex === index && 'bg-accent text-accent-foreground'
)}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex gap-2 items-center truncate pointer-events-none">
<Emoji
emoji={emoji}
classNames={{
img: 'size-8 shrink-0 rounded-md',
text: 'w-8 text-center shrink-0'
}}
/>
<span className="truncate">:{emoji.shortcode}:</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,33 @@
import Emoji from '@/components/Emoji'
import customEmojiService from '@/services/custom-emoji.service'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
import { useMemo } from 'react'
export default function EmojiNode(props: NodeViewRendererProps) {
const emoji = useMemo(() => {
const name = props.node.attrs.name
if (customEmojiService.isCustomEmojiId(name)) {
return customEmojiService.getEmojiById(name)
}
return shortcodeToEmoji(name, emojis)?.emoji
}, [props.node.attrs.name])
if (!emoji) {
return null
}
if (typeof emoji === 'string') {
return (
<NodeViewWrapper className="inline">
<span>{emoji}</span>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="inline">
<Emoji emoji={emoji} classNames={{ img: 'mb-1' }} />
</NodeViewWrapper>
)
}

View File

@@ -0,0 +1,12 @@
import TTEmoji from '@tiptap/extension-emoji'
import { ReactNodeViewRenderer } from '@tiptap/react'
import EmojiNode from './EmojiNode'
const Emoji = TTEmoji.extend({
selectable: true,
addNodeView() {
return ReactNodeViewRenderer(EmojiNode)
}
})
export default Emoji

View File

@@ -0,0 +1,100 @@
import customEmojiService from '@/services/custom-emoji.service'
import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import { EmojiList, EmojiListHandler, EmojiListProps } from './EmojiList'
const suggestion = {
items: async ({ query }: { query: string }) => {
return await customEmojiService.searchEmojis(query)
},
render: () => {
let component: ReactRenderer<EmojiListHandler, EmojiListProps> | undefined
let popup: Instance[] = []
let touchListener: (e: TouchEvent) => void
let closePopup: () => void
return {
onBeforeStart: () => {
touchListener = (e: TouchEvent) => {
if (popup && popup[0] && postEditor.isSuggestionPopupOpen) {
const popupElement = popup[0].popper
if (popupElement && !popupElement.contains(e.target as Node)) {
popup[0].hide()
}
}
}
document.addEventListener('touchstart', touchListener)
closePopup = () => {
if (popup && popup[0]) {
popup[0].hide()
}
}
postEditor.addEventListener('closeSuggestionPopup', closePopup)
},
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(EmojiList, {
props,
editor: props.editor
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
hideOnClick: true,
touch: true,
onShow() {
postEditor.isSuggestionPopupOpen = true
},
onHide() {
postEditor.isSuggestionPopupOpen = false
}
})
},
onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) {
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup[0]?.setProps({
getReferenceClientRect: props.clientRect
} as Partial<Props>)
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
popup[0]?.hide()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit() {
postEditor.isSuggestionPopupOpen = false
popup[0]?.destroy()
component?.destroy()
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
}
}
}
}
export default suggestion

View File

@@ -3,9 +3,9 @@ import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { SuggestionKeyDownProps } from '@tiptap/suggestion' import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import Nip05 from '../../Nip05' import Nip05 from '../../../Nip05'
import { SimpleUserAvatar } from '../../UserAvatar' import { SimpleUserAvatar } from '../../../UserAvatar'
import { SimpleUsername } from '../../Username' import { SimpleUsername } from '../../../Username'
export interface MentionListProps { export interface MentionListProps {
items: string[] items: string[]
@@ -64,7 +64,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
} }
})) }))
if (props.items.length === 0) { if (!props.items?.length) {
return null return null
} }

View File

@@ -1,5 +1,5 @@
import { formatNpub } from '@/lib/pubkey' import { formatNpub } from '@/lib/pubkey'
import Mention from '@tiptap/extension-mention' import TTMention from '@tiptap/extension-mention'
import { ReactNodeViewRenderer } from '@tiptap/react' import { ReactNodeViewRenderer } from '@tiptap/react'
import MentionNode from './MentionNode' import MentionNode from './MentionNode'
@@ -13,7 +13,7 @@ declare module '@tiptap/core' {
// const MENTION_REGEX = /(nostr:)?(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g // const MENTION_REGEX = /(nostr:)?(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
const CustomMention = Mention.extend({ const Mention = TTMention.extend({
selectable: true, selectable: true,
addNodeView() { addNodeView() {
@@ -67,7 +67,7 @@ const CustomMention = Mention.extend({
// ] // ]
// } // }
}) })
export default CustomMention export default Mention
// function handler({ // function handler({
// range, // range,

View File

@@ -12,8 +12,8 @@ const suggestion = {
}, },
render: () => { render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps> let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let popup: Instance[] let popup: Instance[] = []
let touchListener: (e: TouchEvent) => void let touchListener: (e: TouchEvent) => void
let closePopup: () => void let closePopup: () => void
@@ -30,7 +30,6 @@ const suggestion = {
document.addEventListener('touchstart', touchListener) document.addEventListener('touchstart', touchListener)
closePopup = () => { closePopup = () => {
console.log('closePopup')
if (popup && popup[0]) { if (popup && popup[0]) {
popup[0].hide() popup[0].hide()
} }
@@ -67,29 +66,29 @@ const suggestion = {
}, },
onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) { onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) {
component.updateProps(props) component?.updateProps(props)
if (!props.clientRect) { if (!props.clientRect) {
return return
} }
popup[0].setProps({ popup[0]?.setProps({
getReferenceClientRect: props.clientRect getReferenceClientRect: props.clientRect
} as Partial<Props>) } as Partial<Props>)
}, },
onKeyDown(props: SuggestionKeyDownProps) { onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() popup[0]?.hide()
return true return true
} }
return component.ref?.onKeyDown(props) ?? false return component?.ref?.onKeyDown(props) ?? false
}, },
onExit() { onExit() {
postEditor.isSuggestionPopupOpen = false postEditor.isSuggestionPopupOpen = false
popup[0].destroy() popup[0]?.destroy()
component.destroy() component?.destroy()
document.removeEventListener('touchstart', touchListener) document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup) postEditor.removeEventListener('closeSuggestionPopup', closePopup)

View File

@@ -1,12 +1,21 @@
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { transformCustomEmojisInContent } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useMemo } from 'react'
import Content from '../../Content' import Content from '../../Content'
export default function Preview({ content, className }: { content: string; className?: string }) { export default function Preview({ content, className }: { content: string; className?: string }) {
const { content: processedContent, emojiTags } = useMemo(
() => transformCustomEmojisInContent(content),
[content]
)
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className)}>
<Content event={createFakeEvent({ content })} className="pointer-events-none h-full" /> <Content
event={createFakeEvent({ content: processedContent, tags: emojiTags })}
className="pointer-events-none h-full"
/>
</Card> </Card>
) )
} }

View File

@@ -1,7 +1,9 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText } from '@/lib/tiptap' import { parseEditorJsonToText } from '@/lib/tiptap'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import { TEmoji } from '@/types'
import Document from '@tiptap/extension-document' import Document from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break' import { HardBreak } from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history' import History from '@tiptap/extension-history'
@@ -14,13 +16,16 @@ import { Event } from 'nostr-tools'
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react' import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ClipboardAndDropHandler } from './ClipboardAndDropHandler' import { ClipboardAndDropHandler } from './ClipboardAndDropHandler'
import CustomMention from './CustomMention' import Emoji from './Emoji'
import emojiSuggestion from './Emoji/suggestion'
import Mention from './Mention'
import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview' import Preview from './Preview'
import suggestion from './suggestion'
export type TPostTextareaHandle = { export type TPostTextareaHandle = {
appendText: (text: string, addNewline?: boolean) => void appendText: (text: string, addNewline?: boolean) => void
insertText: (text: string) => void insertText: (text: string) => void
insertEmoji: (emoji: string | TEmoji) => void
} }
const PostTextarea = forwardRef< const PostTextarea = forwardRef<
@@ -63,8 +68,11 @@ const PostTextarea = forwardRef<
placeholder: placeholder:
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')' t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
}), }),
CustomMention.configure({ Emoji.configure({
suggestion suggestion: emojiSuggestion
}),
Mention.configure({
suggestion: mentionSuggestion
}), }),
ClipboardAndDropHandler.configure({ ClipboardAndDropHandler.configure({
onUploadStart: (file, cancel) => { onUploadStart: (file, cancel) => {
@@ -130,6 +138,18 @@ const PostTextarea = forwardRef<
if (editor) { if (editor) {
editor.chain().focus().insertContent(text).run() editor.chain().focus().insertContent(text).run()
} }
},
insertEmoji: (emoji: string | TEmoji) => {
if (editor) {
if (typeof emoji === 'string') {
editor.chain().insertContent(emoji).run()
} else {
const emojiNode = editor.schema.nodes.emoji.create({
name: customEmojiService.getEmojiId(emoji)
})
editor.chain().insertContent(emojiNode).insertContent(' ').run()
}
}
} }
})) }))

View File

@@ -53,8 +53,7 @@ export default function ReactionList({ event }: { event: Event }) {
<Emoji <Emoji
emoji={like.emoji} emoji={like.emoji}
classNames={{ classNames={{
text: 'text-xl', text: 'text-xl'
img: 'size-5'
}} }}
/> />
</div> </div>

View File

@@ -1,33 +1,31 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { parseNativeEmoji } from 'emoji-picker-react/src/dataUtils/parseNativeEmoji' import { parseEmojiPickerUnified } from '@/lib/utils'
import { TEmoji } from '@/types'
import { getSuggested } from 'emoji-picker-react/src/dataUtils/suggested' import { getSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
import { MoreHorizontal } from 'lucide-react' import { MoreHorizontal } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Emoji from '../Emoji'
const DEFAULT_SUGGESTED_EMOJIS = ['👍', '❤️', '😂', '🥲', '👀', '🫡', '🫂']
export default function SuggestedEmojis({ export default function SuggestedEmojis({
onEmojiClick, onEmojiClick,
onMoreButtonClick onMoreButtonClick
}: { }: {
onEmojiClick: (emoji: string) => void onEmojiClick: (emoji: string | TEmoji) => void
onMoreButtonClick: () => void onMoreButtonClick: () => void
}) { }) {
const [suggestedEmojis, setSuggestedEmojis] = useState<string[]>([ const [suggestedEmojis, setSuggestedEmojis] =
'1f44d', useState<(string | TEmoji)[]>(DEFAULT_SUGGESTED_EMOJIS)
'2764-fe0f',
'1f602',
'1f972',
'1f440',
'1fae1',
'1fac2'
]) // 👍 ❤️ 😂 🥲 👀 🫡 🫂
useEffect(() => { useEffect(() => {
try { try {
const suggested = getSuggested() const suggested = getSuggested()
const suggestEmojis = suggested.sort((a, b) => b.count - a.count).map((item) => item.unified) const suggestEmojis = suggested
setSuggestedEmojis((pre) => .sort((a, b) => b.count - a.count)
[...suggestEmojis, ...pre.filter((e) => !suggestEmojis.includes(e))].slice(0, 8) .map((item) => parseEmojiPickerUnified(item.unified))
) .filter(Boolean) as (string | TEmoji)[]
setSuggestedEmojis(() => [...suggestEmojis, ...DEFAULT_SUGGESTED_EMOJIS].slice(0, 8))
} catch { } catch {
// ignore // ignore
} }
@@ -35,15 +33,25 @@ export default function SuggestedEmojis({
return ( return (
<div className="flex gap-2 p-1" onClick={(e) => e.stopPropagation()}> <div className="flex gap-2 p-1" onClick={(e) => e.stopPropagation()}>
{suggestedEmojis.map((emoji, index) => ( {suggestedEmojis.map((emoji, index) =>
typeof emoji === 'string' ? (
<div <div
key={index} key={index}
className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl" className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl"
onClick={() => onEmojiClick(parseNativeEmoji(emoji))} onClick={() => onEmojiClick(emoji)}
> >
{parseNativeEmoji(emoji)} {emoji}
</div> </div>
))} ) : (
<div
className="flex flex-col items-center justify-center p-1 rounded-lg clickable"
key={index}
onClick={() => onEmojiClick(emoji)}
>
<Emoji emoji={emoji} classNames={{ img: 'size-6 rounded-md' }} />
</div>
)
)}
<Button variant="ghost" className="w-8 h-8 text-muted-foreground" onClick={onMoreButtonClick}> <Button variant="ghost" className="w-8 h-8 text-muted-foreground" onClick={onMoreButtonClick}>
<MoreHorizontal size={24} /> <MoreHorizontal size={24} />
</Button> </Button>

View File

@@ -339,6 +339,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'لم يتم العثور على قائمة الكتم. هل تريد إنشاء واحدة جديدة؟ إذا كنت قد كتمت مستخدمين من قبل، يرجى عدم التأكيد لأن هذه العملية ستؤدي إلى فقدان قائمة الكتم السابقة.', 'لم يتم العثور على قائمة الكتم. هل تريد إنشاء واحدة جديدة؟ إذا كنت قد كتمت مستخدمين من قبل، يرجى عدم التأكيد لأن هذه العملية ستؤدي إلى فقدان قائمة الكتم السابقة.',
'Show NSFW content by default': 'إظهار محتوى NSFW افتراضياً', 'Show NSFW content by default': 'إظهار محتوى NSFW افتراضياً',
'Custom emoji management': 'إدارة الرموز التعبيرية المخصصة',
'After changing emojis, you may need to refresh the page':
'بعد تغيير الرموز التعبيرية، قد تحتاج إلى تحديث الصفحة',
'Too many read relays': 'Too many read relays', 'Too many read relays': 'Too many read relays',
'Too many write relays': 'Too many write relays', 'Too many write relays': 'Too many write relays',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -346,6 +346,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Stummschaltungsliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer stummgeschaltet haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Stummschaltungsliste verlieren.', 'Stummschaltungsliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer stummgeschaltet haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Stummschaltungsliste verlieren.',
'Show NSFW content by default': 'NSFW-Inhalte standardmäßig anzeigen', 'Show NSFW content by default': 'NSFW-Inhalte standardmäßig anzeigen',
'Custom emoji management': 'Benutzerdefinierte Emoji-Verwaltung',
'After changing emojis, you may need to refresh the page':
'Nach dem Ändern von Emojis müssen Sie möglicherweise die Seite aktualisieren',
'Too many read relays': 'Zu viele Lese-Relays', 'Too many read relays': 'Zu viele Lese-Relays',
'Too many write relays': 'Zu viele Schreib-Relays', 'Too many write relays': 'Zu viele Schreib-Relays',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -340,6 +340,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Mute list not found. Do you want to create a new one? If you have muted users before, please DO NOT confirm as this operation will cause you to lose your previous mute list.', 'Mute list not found. Do you want to create a new one? If you have muted users before, please DO NOT confirm as this operation will cause you to lose your previous mute list.',
'Show NSFW content by default': 'Show NSFW content by default', 'Show NSFW content by default': 'Show NSFW content by default',
'Custom emoji management': 'Custom emoji management',
'After changing emojis, you may need to refresh the page':
'After changing emojis, you may need to refresh the page',
'Too many read relays': 'Too many read relays', 'Too many read relays': 'Too many read relays',
'Too many write relays': 'Too many write relays', 'Too many write relays': 'Too many write relays',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -345,6 +345,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Lista de silenciados no encontrada. ¿Quieres crear una nueva? Si has silenciado usuarios antes, por favor NO confirmes ya que esta operación te hará perder tu lista de silenciados anterior.', 'Lista de silenciados no encontrada. ¿Quieres crear una nueva? Si has silenciado usuarios antes, por favor NO confirmes ya que esta operación te hará perder tu lista de silenciados anterior.',
'Show NSFW content by default': 'Mostrar contenido NSFW por defecto', 'Show NSFW content by default': 'Mostrar contenido NSFW por defecto',
'Custom emoji management': 'Gestión de emojis personalizados',
'After changing emojis, you may need to refresh the page':
'Después de cambiar los emojis, es posible que necesites actualizar la página',
'Too many read relays': 'Demasiados relés de lectura', 'Too many read relays': 'Demasiados relés de lectura',
'Too many write relays': 'Demasiados relés de escritura', 'Too many write relays': 'Demasiados relés de escritura',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -340,6 +340,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'فهرست بی‌صدا شده‌ها پیدا نشد. آیا می‌خواهید یکی جدید ایجاد کنید؟ اگر قبلاً کاربرانی را بی‌صدا کرده‌اید، لطفاً تأیید نکنید زیرا این عملیات باعث از دست رفتن فهرست بی‌صدا شده‌های قبلی شما خواهد شد.', 'فهرست بی‌صدا شده‌ها پیدا نشد. آیا می‌خواهید یکی جدید ایجاد کنید؟ اگر قبلاً کاربرانی را بی‌صدا کرده‌اید، لطفاً تأیید نکنید زیرا این عملیات باعث از دست رفتن فهرست بی‌صدا شده‌های قبلی شما خواهد شد.',
'Show NSFW content by default': 'نمایش محتوای NSFW به صورت پیش‌فرض', 'Show NSFW content by default': 'نمایش محتوای NSFW به صورت پیش‌فرض',
'Custom emoji management': 'مدیریت شکلک‌های سفارشی',
'After changing emojis, you may need to refresh the page':
'پس از تغییر شکلک‌ها، ممکن است نیاز به تازه‌سازی صفحه داشته باشید',
'Too many read relays': 'Too many read relays', 'Too many read relays': 'Too many read relays',
'Too many write relays': 'Too many write relays', 'Too many write relays': 'Too many write relays',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -345,6 +345,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Liste de mise en sourdine non trouvée. Voulez-vous en créer une nouvelle ? Si vous avez mis en sourdine des utilisateurs auparavant, veuillez NE PAS confirmer car cette opération vous fera perdre votre liste de mise en sourdine précédente.', 'Liste de mise en sourdine non trouvée. Voulez-vous en créer une nouvelle ? Si vous avez mis en sourdine des utilisateurs auparavant, veuillez NE PAS confirmer car cette opération vous fera perdre votre liste de mise en sourdine précédente.',
'Show NSFW content by default': 'Afficher le contenu NSFW par défaut', 'Show NSFW content by default': 'Afficher le contenu NSFW par défaut',
'Custom emoji management': 'Gestion des émojis personnalisés',
'After changing emojis, you may need to refresh the page':
'Après avoir modifié les émojis, vous devrez peut-être actualiser la page',
'Too many read relays': 'Trop de relais de lecture', 'Too many read relays': 'Trop de relais de lecture',
'Too many write relays': "Trop de relais d'écriture", 'Too many write relays': "Trop de relais d'écriture",
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -344,6 +344,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Elenco utenti silenziati non trovato. Vuoi crearne uno nuovo? Se hai già silenziato degli utenti in precedenza, per favore NON confermare poiché questa operazione causerà la perdita del tuo elenco utenti silenziati precedente.', 'Elenco utenti silenziati non trovato. Vuoi crearne uno nuovo? Se hai già silenziato degli utenti in precedenza, per favore NON confermare poiché questa operazione causerà la perdita del tuo elenco utenti silenziati precedente.',
'Show NSFW content by default': 'Mostra contenuti NSFW per impostazione predefinita', 'Show NSFW content by default': 'Mostra contenuti NSFW per impostazione predefinita',
'Custom emoji management': 'Gestione emoji personalizzate',
'After changing emojis, you may need to refresh the page':
'Dopo aver modificato le emoji, potrebbe essere necessario aggiornare la pagina',
'Too many read relays': 'Troppi relay di lettura', 'Too many read relays': 'Troppi relay di lettura',
'Too many write relays': 'Troppi relay di scrittura', 'Too many write relays': 'Troppi relay di scrittura',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -342,6 +342,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'ミュートリストが見つかりません。新しいものを作成しますか?以前にユーザーをミュートしたことがある場合は、この操作により前のミュートリストが失われるため、確認しないでください。', 'ミュートリストが見つかりません。新しいものを作成しますか?以前にユーザーをミュートしたことがある場合は、この操作により前のミュートリストが失われるため、確認しないでください。',
'Show NSFW content by default': 'デフォルトでNSFWコンテンツを表示', 'Show NSFW content by default': 'デフォルトでNSFWコンテンツを表示',
'Custom emoji management': 'カスタム絵文字管理',
'After changing emojis, you may need to refresh the page':
'絵文字を変更した後、ページを更新する必要がある場合があります',
'Too many read relays': '読み取りリレイが多すぎます', 'Too many read relays': '読み取りリレイが多すぎます',
'Too many write relays': '書き込みリレイが多すぎます', 'Too many write relays': '書き込みリレイが多すぎます',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -341,6 +341,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'음소거 목록을 찾을 수 없습니다. 새로 만드시겠습니까? 이전에 사용자를 음소거한 적이 있다면 이 작업으로 인해 이전 음소거 목록을 잃게 되므로 확인하지 마시기 바랍니다.', '음소거 목록을 찾을 수 없습니다. 새로 만드시겠습니까? 이전에 사용자를 음소거한 적이 있다면 이 작업으로 인해 이전 음소거 목록을 잃게 되므로 확인하지 마시기 바랍니다.',
'Show NSFW content by default': '기본적으로 NSFW 콘텐츠 표시', 'Show NSFW content by default': '기본적으로 NSFW 콘텐츠 표시',
'Custom emoji management': '사용자 정의 이모지 관리',
'After changing emojis, you may need to refresh the page':
'이모지를 변경한 후 페이지를 새로고침해야 할 수 있습니다',
'Too many read relays': '읽기 릴레이가 너무 많습니다', 'Too many read relays': '읽기 릴레이가 너무 많습니다',
'Too many write relays': '쓰기 릴레이가 너무 많습니다', 'Too many write relays': '쓰기 릴레이가 너무 많습니다',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -344,6 +344,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Lista wyciszonych nie została znaleziona. Czy chcesz utworzyć nową? Jeśli wcześniej wyciszałeś użytkowników, proszę NIE potwierdzaj, ponieważ ta operacja spowoduje utratę poprzedniej listy wyciszonych.', 'Lista wyciszonych nie została znaleziona. Czy chcesz utworzyć nową? Jeśli wcześniej wyciszałeś użytkowników, proszę NIE potwierdzaj, ponieważ ta operacja spowoduje utratę poprzedniej listy wyciszonych.',
'Show NSFW content by default': 'Domyślnie pokazuj treści NSFW', 'Show NSFW content by default': 'Domyślnie pokazuj treści NSFW',
'Custom emoji management': 'Zarządzanie niestandardowymi emoji',
'After changing emojis, you may need to refresh the page':
'Po zmianie emoji może być konieczne odświeżenie strony',
'Too many read relays': 'Too many read relays', 'Too many read relays': 'Too many read relays',
'Too many write relays': 'Too many write relays', 'Too many write relays': 'Too many write relays',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -343,6 +343,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Lista de silenciados não encontrada. Deseja criar uma nova? Se você silenciou usuários antes, por favor NÃO confirme, pois esta operação fará você perder sua lista de silenciados anterior.', 'Lista de silenciados não encontrada. Deseja criar uma nova? Se você silenciou usuários antes, por favor NÃO confirme, pois esta operação fará você perder sua lista de silenciados anterior.',
'Show NSFW content by default': 'Mostrar conteúdo NSFW por padrão', 'Show NSFW content by default': 'Mostrar conteúdo NSFW por padrão',
'Custom emoji management': 'Gerenciamento de emojis personalizados',
'After changing emojis, you may need to refresh the page':
'Após alterar os emojis, você pode precisar atualizar a página',
'Too many read relays': 'Muitos relays de leitura', 'Too many read relays': 'Muitos relays de leitura',
'Too many write relays': 'Muitos relays de escrita', 'Too many write relays': 'Muitos relays de escrita',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -344,6 +344,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Lista de silenciados não encontrada. Deseja criar uma nova? Se silenciou utilizadores anteriormente, por favor NÃO confirme, pois esta operação fará com que perca a sua lista de silenciados anterior.', 'Lista de silenciados não encontrada. Deseja criar uma nova? Se silenciou utilizadores anteriormente, por favor NÃO confirme, pois esta operação fará com que perca a sua lista de silenciados anterior.',
'Show NSFW content by default': 'Mostrar conteúdo NSFW por padrão', 'Show NSFW content by default': 'Mostrar conteúdo NSFW por padrão',
'Custom emoji management': 'Gestão de emojis personalizados',
'After changing emojis, you may need to refresh the page':
'Após alterar os emojis, poderá ser necessário actualizar a página',
'Too many read relays': 'Demasiados relays de leitura', 'Too many read relays': 'Demasiados relays de leitura',
'Too many write relays': 'Demasiados relays de escrita', 'Too many write relays': 'Demasiados relays de escrita',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -344,6 +344,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'Список заблокированных не найден. Хотите создать новый? Если вы уже блокировали пользователей ранее, пожалуйста, НЕ подтверждайте, так как эта операция приведет к потере вашего предыдущего списка заблокированных.', 'Список заблокированных не найден. Хотите создать новый? Если вы уже блокировали пользователей ранее, пожалуйста, НЕ подтверждайте, так как эта операция приведет к потере вашего предыдущего списка заблокированных.',
'Show NSFW content by default': 'Показывать контент NSFW по умолчанию', 'Show NSFW content by default': 'Показывать контент NSFW по умолчанию',
'Custom emoji management': 'Управление пользовательскими эмодзи',
'After changing emojis, you may need to refresh the page':
'После изменения эмодзи может потребоваться обновить страницу',
'Too many read relays': 'Слишком много релеев для чтения', 'Too many read relays': 'Слишком много релеев для чтения',
'Too many write relays': 'Слишком много релеев для записи', 'Too many write relays': 'Слишком много релеев для записи',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -338,6 +338,9 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'ไม่พบรายการปิดเสียง คุณต้องการสร้างรายการใหม่หรือไม่? หากคุณเคยปิดเสียงผู้ใช้มาก่อน กรุณาอย่ายืนยัน เพราะการดำเนินการนี้จะทำให้คุณสูญเสียรายการปิดเสียงก่อนหน้านี้', 'ไม่พบรายการปิดเสียง คุณต้องการสร้างรายการใหม่หรือไม่? หากคุณเคยปิดเสียงผู้ใช้มาก่อน กรุณาอย่ายืนยัน เพราะการดำเนินการนี้จะทำให้คุณสูญเสียรายการปิดเสียงก่อนหน้านี้',
'Show NSFW content by default': 'แสดงเนื้อหา NSFW โดยค่าเริ่มต้น', 'Show NSFW content by default': 'แสดงเนื้อหา NSFW โดยค่าเริ่มต้น',
'Custom emoji management': 'จัดการอีโมจิที่กำหนดเอง',
'After changing emojis, you may need to refresh the page':
'หลังจากเปลี่ยนอีโมจิแล้ว คุณอาจต้องรีเฟรชหน้า',
'Too many read relays': 'Too many read relays', 'Too many read relays': 'Too many read relays',
'Too many write relays': 'Too many write relays', 'Too many write relays': 'Too many write relays',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -337,6 +337,8 @@ export default {
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
'未找到屏蔽列表。你想创建一个新的吗?如果你之前已经屏蔽了用户,请不要确认,因为此操作会导致你丢失之前的屏蔽列表。', '未找到屏蔽列表。你想创建一个新的吗?如果你之前已经屏蔽了用户,请不要确认,因为此操作会导致你丢失之前的屏蔽列表。',
'Show NSFW content by default': '默认显示 NSFW 内容', 'Show NSFW content by default': '默认显示 NSFW 内容',
'Custom emoji management': '自定义表情符号管理',
'After changing emojis, you may need to refresh the page': '更改表情符号后,您可能需要刷新页面',
'Too many read relays': '读取中继过多', 'Too many read relays': '读取中继过多',
'Too many write relays': '写入中继过多', 'Too many write relays': '写入中继过多',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.': 'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':

View File

@@ -1,5 +1,6 @@
import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants' import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { import {
TDraftEvent, TDraftEvent,
@@ -78,14 +79,15 @@ export async function createShortTextNoteDraftEvent(
isNsfw?: boolean isNsfw?: boolean
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const { quoteEventHexIds, quoteReplaceableCoordinates, rootETag, parentETag } = const { quoteEventHexIds, quoteReplaceableCoordinates, rootETag, parentETag } =
await extractRelatedEventIds(content, options.parentEvent) await extractRelatedEventIds(transformedEmojisContent, options.parentEvent)
const hashtags = extractHashtags(content) const hashtags = extractHashtags(transformedEmojisContent)
const tags = hashtags.map((hashtag) => buildTTag(hashtag)) const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
// imeta tags // imeta tags
const images = extractImagesFromContent(content) const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
@@ -120,7 +122,7 @@ export async function createShortTextNoteDraftEvent(
const baseDraft = { const baseDraft = {
kind: kinds.ShortTextNote, kind: kinds.ShortTextNote,
content, content: transformedEmojisContent,
tags tags
} }
const cacheKey = JSON.stringify(baseDraft) const cacheKey = JSON.stringify(baseDraft)
@@ -148,44 +150,6 @@ export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDr
} }
} }
export async function createPictureNoteDraftEvent(
content: string,
pictureInfos: { url: string; tags: string[][] }[],
mentions: string[],
options: {
addClientTag?: boolean
protectedEvent?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(content)
const hashtags = extractHashtags(content)
if (!pictureInfos.length) {
throw new Error('No images found in content')
}
const tags = pictureInfos
.map((info) => buildImetaTag(info.tags))
.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
.concat(mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
}
if (options.protectedEvent) {
tags.push(buildProtectedTag())
}
return {
kind: ExtendedKind.PICTURE,
content,
tags,
created_at: dayjs().unix()
}
}
const commentDraftEventCache: Map<string, TDraftEvent> = new Map() const commentDraftEventCache: Map<string, TDraftEvent> = new Map()
export async function createCommentDraftEvent( export async function createCommentDraftEvent(
content: string, content: string,
@@ -197,6 +161,7 @@ export async function createCommentDraftEvent(
isNsfw?: boolean isNsfw?: boolean
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const { const {
quoteEventHexIds, quoteEventHexIds,
quoteReplaceableCoordinates, quoteReplaceableCoordinates,
@@ -205,15 +170,15 @@ export async function createCommentDraftEvent(
rootKind, rootKind,
rootPubkey, rootPubkey,
rootUrl rootUrl
} = await extractCommentMentions(content, parentEvent) } = await extractCommentMentions(transformedEmojisContent, parentEvent)
const hashtags = extractHashtags(content) const hashtags = extractHashtags(transformedEmojisContent)
const tags = hashtags const tags = emojiTags
.map((hashtag) => buildTTag(hashtag)) .concat(hashtags.map((hashtag) => buildTTag(hashtag)))
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId))) .concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) .concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
const images = extractImagesFromContent(content) const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
@@ -260,7 +225,7 @@ export async function createCommentDraftEvent(
const baseDraft = { const baseDraft = {
kind: ExtendedKind.COMMENT, kind: ExtendedKind.COMMENT,
content, content: transformedEmojisContent,
tags tags
} }
const cacheKey = JSON.stringify(baseDraft) const cacheKey = JSON.stringify(baseDraft)
@@ -374,13 +339,15 @@ export async function createPollDraftEvent(
isNsfw?: boolean isNsfw?: boolean
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(question) const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(question)
const hashtags = extractHashtags(question) const { quoteEventHexIds, quoteReplaceableCoordinates } =
await extractRelatedEventIds(transformedEmojisContent)
const hashtags = extractHashtags(transformedEmojisContent)
const tags = hashtags.map((hashtag) => buildTTag(hashtag)) const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
// imeta tags // imeta tags
const images = extractImagesFromContent(question) const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
@@ -418,7 +385,7 @@ export async function createPollDraftEvent(
} }
const baseDraft = { const baseDraft = {
content: question.trim(), content: transformedEmojisContent.trim(),
kind: ExtendedKind.POLL, kind: ExtendedKind.POLL,
tags tags
} }
@@ -583,6 +550,29 @@ function extractImagesFromContent(content: string) {
return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi) return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi)
} }
export function transformCustomEmojisInContent(content: string) {
const emojiTags: string[][] = []
let processedContent = content
const matches = content.match(/:[a-zA-Z0-9]+:/g)
const emojiIdSet = new Set<string>()
matches?.forEach((m) => {
if (emojiIdSet.has(m)) return
emojiIdSet.add(m)
const emoji = customEmojiService.getEmojiById(m.slice(1, -1))
if (emoji) {
emojiTags.push(buildEmojiTag(emoji))
processedContent = processedContent.replace(new RegExp(m, 'g'), `:${emoji.shortcode}:`)
}
})
return {
emojiTags,
content: processedContent
}
}
export function buildATag(event: Event, upperCase: boolean = false) { export function buildATag(event: Event, upperCase: boolean = false) {
const coordinate = getReplaceableCoordinateFromEvent(event) const coordinate = getReplaceableCoordinateFromEvent(event)
const hint = client.getEventHint(event.id) const hint = client.getEventHint(event.id)
@@ -661,10 +651,6 @@ function buildServerTag(url: string) {
return ['server', url] return ['server', url]
} }
function buildImetaTag(nip94Tags: string[][]) {
return ['imeta', ...nip94Tags.map(([n, v]) => `${n} ${v}`)]
}
function buildResponseTag(value: string) { function buildResponseTag(value: string) {
return ['response', value] return ['response', value]
} }

View File

@@ -1,5 +1,5 @@
import { BIG_RELAY_URLS, POLL_TYPE } from '@/constants' import { BIG_RELAY_URLS, POLL_TYPE } from '@/constants'
import { TPollType, TRelayList, TRelaySet } from '@/types' import { TEmoji, TPollType, TRelayList, TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event' import { buildATag } from './draft-event'
import { getReplaceableEventIdentifier } from './event' import { getReplaceableEventIdentifier } from './event'
@@ -336,3 +336,36 @@ export function getPollResponseFromEvent(
created_at: event.created_at created_at: event.created_at
} }
} }
export function getEmojisAndEmojiSetsFromEvent(event: Event) {
const emojis: TEmoji[] = []
const emojiSetPointers: string[] = []
event.tags.forEach(([tagName, ...tagValues]) => {
if (tagName === 'emoji' && tagValues.length >= 2) {
emojis.push({
shortcode: tagValues[0],
url: tagValues[1]
})
} else if (tagName === 'a' && tagValues[0]) {
emojiSetPointers.push(tagValues[0])
}
})
return { emojis, emojiSetPointers }
}
export function getEmojisFromEvent(event: Event): TEmoji[] {
const emojis: TEmoji[] = []
event.tags.forEach(([tagName, ...tagValues]) => {
if (tagName === 'emoji' && tagValues.length >= 2) {
emojis.push({
shortcode: tagValues[0],
url: tagValues[1]
})
}
})
return emojis
}

View File

@@ -1,3 +1,5 @@
import customEmojiService from '@/services/custom-emoji.service'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { JSONContent } from '@tiptap/react' import { JSONContent } from '@tiptap/react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
@@ -38,7 +40,18 @@ function _parseEditorJsonToText(node?: JSONContent): string {
return '\n' return '\n'
case 'mention': case 'mention':
return node.attrs ? `nostr:${node.attrs.id}` : '' return node.attrs ? `nostr:${node.attrs.id}` : ''
case 'emoji':
return parseEmojiNodeName(node.attrs?.name)
default: default:
return '' return ''
} }
} }
function parseEmojiNodeName(name?: string): string {
if (!name) return ''
if (customEmojiService.isCustomEmojiId(name)) {
return `:${name}:`
}
const emoji = shortcodeToEmoji(name, emojis)
return emoji ? (emoji.emoji ?? '') : ''
}

View File

@@ -7,7 +7,9 @@ import {
URL_REGEX, URL_REGEX,
WS_URL_REGEX WS_URL_REGEX
} from '@/constants' } from '@/constants'
import { TEmoji } from '@/types'
import { clsx, type ClassValue } from 'clsx' import { clsx, type ClassValue } from 'clsx'
import { parseNativeEmoji } from 'emoji-picker-react/src/dataUtils/parseNativeEmoji'
import { franc } from 'franc-min' import { franc } from 'franc-min'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
@@ -133,3 +135,16 @@ export function detectLanguage(text?: string): string | null {
return 'und' return 'und'
} }
} }
export function parseEmojiPickerUnified(unified: string): string | TEmoji | undefined {
if (unified.startsWith(':')) {
const secondColonIndex = unified.indexOf(':', 1)
if (secondColonIndex < 0) return undefined
const shortcode = unified.slice(1, secondColonIndex)
const url = unified.slice(secondColonIndex + 1)
return { shortcode, url }
} else {
return parseNativeEmoji(unified)
}
}

View File

@@ -8,6 +8,7 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useTheme } from '@/providers/ThemeProvider' import { useTheme } from '@/providers/ThemeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { SelectValue } from '@radix-ui/react-select' import { SelectValue } from '@radix-ui/react-select'
import { ExternalLink } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react' import { forwardRef, HTMLProps, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -81,6 +82,22 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</Label> </Label>
<Switch id="show-nsfw" checked={defaultShowNsfw} onCheckedChange={setDefaultShowNsfw} /> <Switch id="show-nsfw" checked={defaultShowNsfw} onCheckedChange={setDefaultShowNsfw} />
</SettingItem> </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> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

View File

@@ -10,6 +10,7 @@ import { getLatestEvent, getReplaceableEventIdentifier } from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { formatPubkey, isValidPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, isValidPubkey, pubkeyToNpub } from '@/lib/pubkey'
import client from '@/services/client.service' import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
@@ -43,6 +44,7 @@ type TNostrContext = {
muteListEvent: Event | null muteListEvent: Event | null
bookmarkListEvent: Event | null bookmarkListEvent: Event | null
favoriteRelaysEvent: Event | null favoriteRelaysEvent: Event | null
userEmojiListEvent: Event | null
notificationsSeenAt: number notificationsSeenAt: number
account: TAccountPointer | null account: TAccountPointer | null
accounts: TAccountPointer[] accounts: TAccountPointer[]
@@ -104,6 +106,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [muteListEvent, setMuteListEvent] = useState<Event | null>(null) const [muteListEvent, setMuteListEvent] = useState<Event | null>(null)
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null) const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null) const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1) const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
@@ -173,14 +176,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
storedFollowListEvent, storedFollowListEvent,
storedMuteListEvent, storedMuteListEvent,
storedBookmarkListEvent, storedBookmarkListEvent,
storedFavoriteRelaysEvent storedFavoriteRelaysEvent,
storedUserEmojiListEvent
] = await Promise.all([ ] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata), indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts), indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist), indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist),
indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList), indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS) indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList)
]) ])
if (storedRelayListEvent) { if (storedRelayListEvent) {
setRelayList(getRelayListFromEvent(storedRelayListEvent)) setRelayList(getRelayListFromEvent(storedRelayListEvent))
@@ -201,6 +206,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (storedFavoriteRelaysEvent) { if (storedFavoriteRelaysEvent) {
setFavoriteRelaysEvent(storedFavoriteRelaysEvent) setFavoriteRelaysEvent(storedFavoriteRelaysEvent)
} }
if (storedUserEmojiListEvent) {
setUserEmojiListEvent(storedUserEmojiListEvent)
}
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, { const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
@@ -222,7 +230,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.Mutelist, kinds.Mutelist,
kinds.BookmarkList, kinds.BookmarkList,
ExtendedKind.FAVORITE_RELAYS, ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList
], ],
authors: [account.pubkey] authors: [account.pubkey]
}, },
@@ -241,15 +250,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const blossomServerListEvent = sortedEvents.find( const blossomServerListEvent = sortedEvents.find(
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST (e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
) )
const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList)
const notificationsSeenAtEvent = sortedEvents.find( const notificationsSeenAtEvent = sortedEvents.find(
(e) => (e) =>
e.kind === kinds.Application && e.kind === kinds.Application &&
getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT
) )
if (profileEvent) { if (profileEvent) {
setProfileEvent(profileEvent) const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
setProfile(getProfileFromEvent(profileEvent)) if (updatedProfileEvent.id === profileEvent.id) {
await indexedDb.putReplaceableEvent(profileEvent) setProfileEvent(updatedProfileEvent)
setProfile(getProfileFromEvent(updatedProfileEvent))
}
} else if (!storedProfileEvent) { } else if (!storedProfileEvent) {
setProfile({ setProfile({
pubkey: account.pubkey, pubkey: account.pubkey,
@@ -258,24 +270,38 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
} }
if (followListEvent) { if (followListEvent) {
const updatedFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent)
if (updatedFollowListEvent.id === followListEvent.id) {
setFollowListEvent(followListEvent) setFollowListEvent(followListEvent)
await indexedDb.putReplaceableEvent(followListEvent) }
} }
if (muteListEvent) { if (muteListEvent) {
const updatedMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
if (updatedMuteListEvent.id === muteListEvent.id) {
setMuteListEvent(muteListEvent) setMuteListEvent(muteListEvent)
await indexedDb.putReplaceableEvent(muteListEvent) }
} }
if (bookmarkListEvent) { if (bookmarkListEvent) {
const updateBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent)
if (updateBookmarkListEvent.id === bookmarkListEvent.id) {
setBookmarkListEvent(bookmarkListEvent) setBookmarkListEvent(bookmarkListEvent)
await indexedDb.putReplaceableEvent(bookmarkListEvent) }
} }
if (favoriteRelaysEvent) { if (favoriteRelaysEvent) {
setFavoriteRelaysEvent(favoriteRelaysEvent) const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
await indexedDb.putReplaceableEvent(favoriteRelaysEvent) if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) {
setFavoriteRelaysEvent(updatedFavoriteRelaysEvent)
}
} }
if (blossomServerListEvent) { if (blossomServerListEvent) {
await client.updateBlossomServerListEventCache(blossomServerListEvent) await client.updateBlossomServerListEventCache(blossomServerListEvent)
} }
if (userEmojiListEvent) {
const updatedUserEmojiListEvent = await indexedDb.putReplaceableEvent(userEmojiListEvent)
if (updatedUserEmojiListEvent.id === userEmojiListEvent.id) {
setUserEmojiListEvent(updatedUserEmojiListEvent)
}
}
const notificationsSeenAt = Math.max( const notificationsSeenAt = Math.max(
notificationsSeenAtEvent?.created_at ?? 0, notificationsSeenAtEvent?.created_at ?? 0,
@@ -334,6 +360,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
}, [account]) }, [account])
useEffect(() => {
customEmojiService.init(userEmojiListEvent)
}, [userEmojiListEvent])
const hasNostrLoginHash = () => { const hasNostrLoginHash = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login') return window.location.hash && window.location.hash.startsWith('#nostr-login')
} }
@@ -734,6 +764,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
muteListEvent, muteListEvent,
bookmarkListEvent, bookmarkListEvent,
favoriteRelaysEvent, favoriteRelaysEvent,
userEmojiListEvent,
notificationsSeenAt, notificationsSeenAt,
account, account,
accounts, accounts,

View File

@@ -1,7 +1,6 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { import {
compareEvents, compareEvents,
getLatestEvent,
getReplaceableCoordinate, getReplaceableCoordinate,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
isReplaceableEvent isReplaceableEvent
@@ -10,6 +9,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag' import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { isSafari } from '@/lib/utils'
import { ISigner, TProfile, TRelayList, TSubRequestFilter } from '@/types' import { ISigner, TProfile, TRelayList, TSubRequestFilter } from '@/types'
import { sha256 } from '@noble/hashes/sha2' import { sha256 } from '@noble/hashes/sha2'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
@@ -27,7 +27,6 @@ import {
} from 'nostr-tools' } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay' import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import { isSafari } from '@/lib/utils'
type TTimelineRef = [string, number] type TTimelineRef = [string, number]
@@ -1094,47 +1093,78 @@ class ClientService extends EventTarget {
/** =========== Replaceable event dataloader =========== */ /** =========== Replaceable event dataloader =========== */
private replaceableEventDataLoader = new DataLoader< private replaceableEventDataLoader = new DataLoader<
{ pubkey: string; kind: number }, { pubkey: string; kind: number; d?: string },
NEvent | null, NEvent | null,
string string
>(this.replaceableEventBatchLoadFn.bind(this), { >(this.replaceableEventBatchLoadFn.bind(this), {
cacheKeyFn: ({ pubkey, kind }) => `${pubkey}:${kind}` cacheKeyFn: ({ pubkey, kind, d }) => `${kind}:${pubkey}:${d ?? ''}`
}) })
private async replaceableEventBatchLoadFn(params: readonly { pubkey: string; kind: number }[]) { private async replaceableEventBatchLoadFn(
const results = await Promise.allSettled( params: readonly { pubkey: string; kind: number; d?: string }[]
params.map(async ({ pubkey, kind }) => { ) {
const relayList = await this.fetchRelayList(pubkey) const groups = new Map<string, { kind: number; d?: string }[]>()
const events = await this.query(relayList.write.concat(BIG_RELAY_URLS).slice(0, 5), { params.forEach(({ pubkey, kind, d }) => {
authors: [pubkey], if (!groups.has(pubkey)) {
kinds: [kind] groups.set(pubkey, [])
}) }
const event = getLatestEvent(events) ?? null groups.get(pubkey)!.push({ kind: kind, d })
if (event) { })
indexedDb.putReplaceableEvent(event)
} else { const eventMap = new Map<string, NEvent | null>()
indexedDb.putNullReplaceableEvent(pubkey, kind) await Promise.allSettled(
Array.from(groups.entries()).map(async ([pubkey, _params]) => {
const groupByKind = new Map<number, string[]>()
_params.forEach(({ kind, d }) => {
if (!groupByKind.has(kind)) {
groupByKind.set(kind, [])
}
if (d) {
groupByKind.get(kind)!.push(d)
}
})
const filters = Array.from(groupByKind.entries()).map(
([kind, dList]) =>
(dList.length > 0
? {
authors: [pubkey],
kinds: [kind],
'#d': dList
}
: { authors: [pubkey], kinds: [kind] }) as Filter
)
const events = await this.query(BIG_RELAY_URLS, filters)
for (const event of events) {
const key = getReplaceableCoordinateFromEvent(event)
const existing = eventMap.get(key)
if (!existing || existing.created_at < event.created_at) {
eventMap.set(key, event)
}
} }
return event
}) })
) )
return results.map((result) => {
if (result.status === 'fulfilled') { return params.map(({ pubkey, kind, d }) => {
return result.value const key = `${kind}:${pubkey}:${d ?? ''}`
const event = eventMap.get(key)
if (event) {
indexedDb.putReplaceableEvent(event)
return event
} else { } else {
console.error('Failed to load replaceable event:', result.reason) indexedDb.putNullReplaceableEvent(pubkey, kind, d)
return null return null
} }
}) })
} }
private async fetchReplaceableEvent(pubkey: string, kind: number) { private async fetchReplaceableEvent(pubkey: string, kind: number, d?: string) {
const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind) const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind, d)
if (storedEvent !== undefined) { if (storedEvent !== undefined) {
return storedEvent return storedEvent
} }
return await this.replaceableEventDataLoader.load({ pubkey, kind }) return await this.replaceableEventDataLoader.load({ pubkey, kind, d })
} }
private async updateReplaceableEventCache(event: NEvent) { private async updateReplaceableEventCache(event: NEvent) {
@@ -1182,6 +1212,21 @@ class ClientService extends EventTarget {
await this.updateReplaceableEventCache(evt) await this.updateReplaceableEventCache(evt)
} }
async fetchEmojiSetEvents(pointers: string[]) {
const params = pointers
.map((pointer) => {
const [kindStr, pubkey, d = ''] = pointer.split(':')
if (!pubkey || !kindStr) return null
const kind = parseInt(kindStr, 10)
if (kind !== kinds.Emojisets) return null
return { pubkey, kind, d }
})
.filter(Boolean) as { pubkey: string; kind: number; d: string }[]
return await this.replaceableEventDataLoader.loadMany(params)
}
// ================= Utils ================= // ================= Utils =================
async generateSubRequestsForPubkeys(pubkeys: string[], myPubkey?: string | null) { async generateSubRequestsForPubkeys(pubkeys: string[], myPubkey?: string | null) {

View File

@@ -0,0 +1,118 @@
import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata'
import { parseEmojiPickerUnified } from '@/lib/utils'
import client from '@/services/client.service'
import { TEmoji } from '@/types'
import { sha256 } from '@noble/hashes/sha2'
import { SkinTones } from 'emoji-picker-react'
import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
import FlexSearch from 'flexsearch'
import { Event } from 'nostr-tools'
class CustomEmojiService {
static instance: CustomEmojiService
private emojiMap = new Map<string, TEmoji>()
private emojiIndex = new FlexSearch.Index({
tokenize: 'full'
})
constructor() {
if (!CustomEmojiService.instance) {
CustomEmojiService.instance = this
}
return CustomEmojiService.instance
}
async init(userEmojiListEvent: Event | null) {
if (!userEmojiListEvent) return
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(userEmojiListEvent)
await this.addEmojisToIndex(emojis)
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers)
await Promise.allSettled(
emojiSetEvents.map(async (event) => {
if (!event || event instanceof Error) return
await this.addEmojisToIndex(getEmojisFromEvent(event))
})
)
}
async searchEmojis(query: string = ''): Promise<string[]> {
if (!query) {
const idSet = new Set<string>()
getSuggested()
.sort((a, b) => b.count - a.count)
.map((item) => parseEmojiPickerUnified(item.unified))
.forEach((item) => {
if (item && typeof item !== 'string') {
const id = this.getEmojiId(item)
if (!idSet.has(id)) {
idSet.add(id)
}
}
})
for (const key of this.emojiMap.keys()) {
idSet.add(key)
}
return Array.from(idSet)
}
const results = await this.emojiIndex.searchAsync(query)
return results.filter((id) => typeof id === 'string') as string[]
}
getEmojiById(id?: string): TEmoji | undefined {
if (!id) return undefined
return this.emojiMap.get(id)
}
getAllCustomEmojisForPicker() {
return Array.from(this.emojiMap.values()).map((emoji) => ({
id: `:${emoji.shortcode}:${emoji.url}`,
imgUrl: emoji.url,
names: [emoji.shortcode]
}))
}
isCustomEmojiId(shortcode: string) {
return this.emojiMap.has(shortcode)
}
private async addEmojisToIndex(emojis: TEmoji[]) {
await Promise.allSettled(
emojis.map(async (emoji) => {
const id = this.getEmojiId(emoji)
this.emojiMap.set(id, emoji)
await this.emojiIndex.addAsync(id, emoji.shortcode)
})
)
}
getEmojiId(emoji: TEmoji) {
const encoder = new TextEncoder()
const data = encoder.encode(`${emoji.shortcode}:${emoji.url}`.toLowerCase())
const hashBuffer = sha256(data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
updateSuggested(id: string) {
const emoji = this.getEmojiById(id)
if (!emoji) return
setSuggested(
{
n: [emoji.shortcode.toLowerCase()],
u: `:${emoji.shortcode}:${emoji.url}`.toLowerCase(),
a: '0',
imgUrl: emoji.url
},
SkinTones.NEUTRAL
)
}
}
const instance = new CustomEmojiService()
export default instance

View File

@@ -17,6 +17,8 @@ const StoreNames = {
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
RELAY_INFO_EVENTS: 'relayInfoEvents', RELAY_INFO_EVENTS: 'relayInfoEvents',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents',
FAVORITE_RELAYS: 'favoriteRelays', FAVORITE_RELAYS: 'favoriteRelays',
RELAY_SETS: 'relaySets', RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays' FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays'
@@ -38,7 +40,7 @@ class IndexedDbService {
init(): Promise<void> { init(): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => { this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 6) const request = window.indexedDB.open('jumble', 7)
request.onerror = (event) => { request.onerror = (event) => {
reject(event) reject(event)
@@ -84,6 +86,12 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.USER_EMOJI_LIST_EVENTS)) {
db.createObjectStore(StoreNames.USER_EMOJI_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) {
db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' })
}
this.db = db this.db = db
} }
}) })
@@ -92,7 +100,7 @@ class IndexedDbService {
return this.initPromise return this.initPromise
} }
async putNullReplaceableEvent(pubkey: string, kind: number) { async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) {
const storeName = this.getStoreNameByKind(kind) const storeName = this.getStoreNameByKind(kind)
if (!storeName) { if (!storeName) {
return Promise.reject('store name not found') return Promise.reject('store name not found')
@@ -105,14 +113,15 @@ class IndexedDbService {
const transaction = this.db.transaction(storeName, 'readwrite') const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const getRequest = store.get(pubkey) const key = this.getReplaceableEventKey(pubkey, d)
const getRequest = store.get(key)
getRequest.onsuccess = () => { getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue) { if (oldValue) {
transaction.commit() transaction.commit()
return resolve(oldValue.value) return resolve(oldValue.value)
} }
const putRequest = store.put(this.formatValue(pubkey, null)) const putRequest = store.put(this.formatValue(key, null))
putRequest.onsuccess = () => { putRequest.onsuccess = () => {
transaction.commit() transaction.commit()
resolve(null) resolve(null)
@@ -144,7 +153,7 @@ class IndexedDbService {
const transaction = this.db.transaction(storeName, 'readwrite') const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKey(event) const key = this.getReplaceableEventKeyFromEvent(event)
const getRequest = store.get(key) const getRequest = store.get(key)
getRequest.onsuccess = () => { getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined const oldValue = getRequest.result as TValue<Event> | undefined
@@ -187,7 +196,7 @@ class IndexedDbService {
} }
const transaction = this.db.transaction(storeName, 'readonly') const transaction = this.db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName) const store = transaction.objectStore(storeName)
const key = d === undefined ? pubkey : `${pubkey}:${d}` const key = this.getReplaceableEventKey(pubkey, d)
const request = store.get(key) const request = store.get(key)
request.onsuccess = () => { request.onsuccess = () => {
@@ -220,7 +229,7 @@ class IndexedDbService {
const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined) const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined)
let count = 0 let count = 0
pubkeys.forEach((pubkey, i) => { pubkeys.forEach((pubkey, i) => {
const request = store.get(pubkey) const request = store.get(this.getReplaceableEventKey(pubkey))
request.onsuccess = () => { request.onsuccess = () => {
const event = (request.result as TValue<Event | null>)?.value const event = (request.result as TValue<Event | null>)?.value
@@ -415,16 +424,20 @@ class IndexedDbService {
}) })
} }
private getReplaceableEventKey(event: Event): string { private getReplaceableEventKeyFromEvent(event: Event): string {
if ( if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) || [kinds.Metadata, kinds.Contacts].includes(event.kind) ||
(event.kind >= 10000 && event.kind < 20000) (event.kind >= 10000 && event.kind < 20000)
) { ) {
return event.pubkey return this.getReplaceableEventKey(event.pubkey)
} }
const [, d] = event.tags.find(tagNameEquals('d')) ?? [] const [, d] = event.tags.find(tagNameEquals('d')) ?? []
return `${event.pubkey}:${d ?? ''}` return this.getReplaceableEventKey(event.pubkey, d)
}
private getReplaceableEventKey(pubkey: string, d?: string): string {
return d === undefined ? pubkey : `${pubkey}:${d}`
} }
private getStoreNameByKind(kind: number): string | undefined { private getStoreNameByKind(kind: number): string | undefined {
@@ -445,6 +458,10 @@ class IndexedDbService {
return StoreNames.FAVORITE_RELAYS return StoreNames.FAVORITE_RELAYS
case kinds.BookmarkList: case kinds.BookmarkList:
return StoreNames.BOOKMARK_LIST_EVENTS return StoreNames.BOOKMARK_LIST_EVENTS
case kinds.UserEmojiList:
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:
return StoreNames.EMOJI_SET_EVENTS
default: default:
return undefined return undefined
} }