feat: custom emoji
This commit is contained in:
55
package-lock.json
generated
55
package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tiptap/extension-emoji": "^2.26.1",
|
||||
"@tiptap/extension-history": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
@@ -4343,6 +4344,30 @@
|
||||
"@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": {
|
||||
"version": "2.12.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -7649,6 +7699,11 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tiptap/extension-emoji": "^2.26.1",
|
||||
"@tiptap/extension-history": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
|
||||
@@ -130,7 +130,7 @@ const Content = memo(
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||
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') {
|
||||
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Content({
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos?.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji key={index} emoji={emoji} />
|
||||
return <Emoji key={index} emoji={emoji} classNames={{ img: 'mb-1' }} />
|
||||
}
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function Emoji({
|
||||
|
||||
if (typeof emoji === 'string') {
|
||||
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>
|
||||
)
|
||||
@@ -33,7 +33,7 @@ export default function Emoji({
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className={cn('inline-block size-4', classNames?.img)}
|
||||
className={cn('inline-block size-5 rounded-sm', classNames?.img)}
|
||||
onLoad={() => {
|
||||
setHasError(false)
|
||||
}}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { parseEmojiPickerUnified } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import customEmojiService from '@/services/custom-emoji.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import EmojiPickerReact, {
|
||||
EmojiStyle,
|
||||
SkinTonePickerLocation,
|
||||
SuggestionMode,
|
||||
Theme
|
||||
} 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 { isSmallScreen } = useScreenSize()
|
||||
|
||||
@@ -31,7 +37,11 @@ export default function EmojiPicker({ onEmojiClick }: { onEmojiClick: MouseDownE
|
||||
} as React.CSSProperties
|
||||
}
|
||||
suggestedEmojisMode={SuggestionMode.FREQUENT}
|
||||
onEmojiClick={onEmojiClick}
|
||||
onEmojiClick={(data, e) => {
|
||||
const emoji = parseEmojiPickerUnified(data.unified)
|
||||
onEmojiClick(emoji, e)
|
||||
}}
|
||||
customEmojis={customEmojiService.getAllCustomEmojisForPicker()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TEmoji } from '@/types'
|
||||
import { useState } from 'react'
|
||||
import EmojiPicker from '../EmojiPicker'
|
||||
|
||||
@@ -13,7 +14,7 @@ export default function EmojiPickerDialog({
|
||||
onEmojiClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onEmojiClick?: (emoji: string) => void
|
||||
onEmojiClick?: (emoji: string | TEmoji | undefined) => void
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -24,10 +25,10 @@ export default function EmojiPickerDialog({
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<EmojiPicker
|
||||
onEmojiClick={(data, e) => {
|
||||
onEmojiClick={(emoji, e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
onEmojiClick?.(data.emoji)
|
||||
onEmojiClick?.(emoji)
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
@@ -40,10 +41,10 @@ export default function EmojiPickerDialog({
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="p-0 w-fit">
|
||||
<EmojiPicker
|
||||
onEmojiClick={(data, e) => {
|
||||
onEmojiClick={(emoji, e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
onEmojiClick?.(data.emoji)
|
||||
onEmojiClick?.(emoji)
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import { Loader, SmilePlus } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -37,7 +38,7 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
return { myLastEmoji: myLike?.emoji, likeCount: likes?.length }
|
||||
}, [noteStats, pubkey, hideUntrustedInteractions])
|
||||
|
||||
const like = async (emoji: string) => {
|
||||
const like = async (emoji: string | TEmoji) => {
|
||||
checkLogin(async () => {
|
||||
if (liking || !pubkey) return
|
||||
|
||||
@@ -75,9 +76,7 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
<Loader className="animate-spin" />
|
||||
) : myLastEmoji ? (
|
||||
<>
|
||||
<div className="h-5 w-5 flex items-center justify-center">
|
||||
<Emoji emoji={myLastEmoji} />
|
||||
</div>
|
||||
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||
</>
|
||||
) : (
|
||||
@@ -97,9 +96,11 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
<DrawerOverlay onClick={() => setIsEmojiReactionsOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<EmojiPicker
|
||||
onEmojiClick={(data) => {
|
||||
onEmojiClick={(emoji) => {
|
||||
setIsEmojiReactionsOpen(false)
|
||||
like(data.emoji)
|
||||
if (!emoji) return
|
||||
|
||||
like(emoji)
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
@@ -122,10 +123,12 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
<DropdownMenuContent side="top" className="p-0 w-fit">
|
||||
{isPickerOpen ? (
|
||||
<EmojiPicker
|
||||
onEmojiClick={(data, e) => {
|
||||
onEmojiClick={(emoji, e) => {
|
||||
e.stopPropagation()
|
||||
setIsEmojiReactionsOpen(false)
|
||||
like(data.emoji)
|
||||
if (!emoji) return
|
||||
|
||||
like(emoji)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -71,7 +71,11 @@ export default function Likes({ event }: { event: Event }) {
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -254,7 +254,12 @@ export default function PostContent({
|
||||
opening the emoji picker drawer causes an issue,
|
||||
the emoji I tap isn't the one that gets inserted. */}
|
||||
{!isTouchDevice() && (
|
||||
<EmojiPickerDialog onEmojiClick={(emoji) => textareaRef.current?.insertText(emoji)}>
|
||||
<EmojiPickerDialog
|
||||
onEmojiClick={(emoji) => {
|
||||
if (!emoji) return
|
||||
textareaRef.current?.insertEmoji(emoji)
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Smile />
|
||||
</Button>
|
||||
|
||||
131
src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx
Normal file
131
src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx
Normal file
33
src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
src/components/PostEditor/PostTextarea/Emoji/index.tsx
Normal file
12
src/components/PostEditor/PostTextarea/Emoji/index.tsx
Normal 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
|
||||
100
src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
Normal file
100
src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
Normal 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
|
||||
@@ -3,9 +3,9 @@ import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import Nip05 from '../../Nip05'
|
||||
import { SimpleUserAvatar } from '../../UserAvatar'
|
||||
import { SimpleUsername } from '../../Username'
|
||||
import Nip05 from '../../../Nip05'
|
||||
import { SimpleUserAvatar } from '../../../UserAvatar'
|
||||
import { SimpleUsername } from '../../../Username'
|
||||
|
||||
export interface MentionListProps {
|
||||
items: string[]
|
||||
@@ -64,7 +64,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
|
||||
}
|
||||
}))
|
||||
|
||||
if (props.items.length === 0) {
|
||||
if (!props.items?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatNpub } from '@/lib/pubkey'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
import TTMention from '@tiptap/extension-mention'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
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 CustomMention = Mention.extend({
|
||||
const Mention = TTMention.extend({
|
||||
selectable: true,
|
||||
|
||||
addNodeView() {
|
||||
@@ -67,7 +67,7 @@ const CustomMention = Mention.extend({
|
||||
// ]
|
||||
// }
|
||||
})
|
||||
export default CustomMention
|
||||
export default Mention
|
||||
|
||||
// function handler({
|
||||
// range,
|
||||
@@ -12,8 +12,8 @@ const suggestion = {
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<MentionListHandle, MentionListProps>
|
||||
let popup: Instance[]
|
||||
let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined
|
||||
let popup: Instance[] = []
|
||||
let touchListener: (e: TouchEvent) => void
|
||||
let closePopup: () => void
|
||||
|
||||
@@ -30,7 +30,6 @@ const suggestion = {
|
||||
document.addEventListener('touchstart', touchListener)
|
||||
|
||||
closePopup = () => {
|
||||
console.log('closePopup')
|
||||
if (popup && popup[0]) {
|
||||
popup[0].hide()
|
||||
}
|
||||
@@ -67,29 +66,29 @@ const suggestion = {
|
||||
},
|
||||
|
||||
onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) {
|
||||
component.updateProps(props)
|
||||
component?.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect
|
||||
} as Partial<Props>)
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
popup[0]?.hide()
|
||||
return true
|
||||
}
|
||||
return component.ref?.onKeyDown(props) ?? false
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit() {
|
||||
postEditor.isSuggestionPopupOpen = false
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
popup[0]?.destroy()
|
||||
component?.destroy()
|
||||
|
||||
document.removeEventListener('touchstart', touchListener)
|
||||
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
|
||||
@@ -1,12 +1,21 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { transformCustomEmojisInContent } from '@/lib/draft-event'
|
||||
import { createFakeEvent } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
import Content from '../../Content'
|
||||
|
||||
export default function Preview({ content, className }: { content: string; className?: string }) {
|
||||
const { content: processedContent, emojiTags } = useMemo(
|
||||
() => transformCustomEmojisInContent(content),
|
||||
[content]
|
||||
)
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { parseEditorJsonToText } from '@/lib/tiptap'
|
||||
import { cn } from '@/lib/utils'
|
||||
import customEmojiService from '@/services/custom-emoji.service'
|
||||
import postEditorCache from '@/services/post-editor-cache.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import { HardBreak } from '@tiptap/extension-hard-break'
|
||||
import History from '@tiptap/extension-history'
|
||||
@@ -14,13 +16,16 @@ import { Event } from 'nostr-tools'
|
||||
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 suggestion from './suggestion'
|
||||
|
||||
export type TPostTextareaHandle = {
|
||||
appendText: (text: string, addNewline?: boolean) => void
|
||||
insertText: (text: string) => void
|
||||
insertEmoji: (emoji: string | TEmoji) => void
|
||||
}
|
||||
|
||||
const PostTextarea = forwardRef<
|
||||
@@ -63,8 +68,11 @@ const PostTextarea = forwardRef<
|
||||
placeholder:
|
||||
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
||||
}),
|
||||
CustomMention.configure({
|
||||
suggestion
|
||||
Emoji.configure({
|
||||
suggestion: emojiSuggestion
|
||||
}),
|
||||
Mention.configure({
|
||||
suggestion: mentionSuggestion
|
||||
}),
|
||||
ClipboardAndDropHandler.configure({
|
||||
onUploadStart: (file, cancel) => {
|
||||
@@ -130,6 +138,18 @@ const PostTextarea = forwardRef<
|
||||
if (editor) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -53,8 +53,7 @@ export default function ReactionList({ event }: { event: Event }) {
|
||||
<Emoji
|
||||
emoji={like.emoji}
|
||||
classNames={{
|
||||
text: 'text-xl',
|
||||
img: 'size-5'
|
||||
text: 'text-xl'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
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 { MoreHorizontal } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Emoji from '../Emoji'
|
||||
|
||||
const DEFAULT_SUGGESTED_EMOJIS = ['👍', '❤️', '😂', '🥲', '👀', '🫡', '🫂']
|
||||
|
||||
export default function SuggestedEmojis({
|
||||
onEmojiClick,
|
||||
onMoreButtonClick
|
||||
}: {
|
||||
onEmojiClick: (emoji: string) => void
|
||||
onEmojiClick: (emoji: string | TEmoji) => void
|
||||
onMoreButtonClick: () => void
|
||||
}) {
|
||||
const [suggestedEmojis, setSuggestedEmojis] = useState<string[]>([
|
||||
'1f44d',
|
||||
'2764-fe0f',
|
||||
'1f602',
|
||||
'1f972',
|
||||
'1f440',
|
||||
'1fae1',
|
||||
'1fac2'
|
||||
]) // 👍 ❤️ 😂 🥲 👀 🫡 🫂
|
||||
const [suggestedEmojis, setSuggestedEmojis] =
|
||||
useState<(string | TEmoji)[]>(DEFAULT_SUGGESTED_EMOJIS)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const suggested = getSuggested()
|
||||
const suggestEmojis = suggested.sort((a, b) => b.count - a.count).map((item) => item.unified)
|
||||
setSuggestedEmojis((pre) =>
|
||||
[...suggestEmojis, ...pre.filter((e) => !suggestEmojis.includes(e))].slice(0, 8)
|
||||
)
|
||||
const suggestEmojis = suggested
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((item) => parseEmojiPickerUnified(item.unified))
|
||||
.filter(Boolean) as (string | TEmoji)[]
|
||||
setSuggestedEmojis(() => [...suggestEmojis, ...DEFAULT_SUGGESTED_EMOJIS].slice(0, 8))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -35,15 +33,25 @@ export default function SuggestedEmojis({
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 p-1" onClick={(e) => e.stopPropagation()}>
|
||||
{suggestedEmojis.map((emoji, index) => (
|
||||
{suggestedEmojis.map((emoji, index) =>
|
||||
typeof emoji === 'string' ? (
|
||||
<div
|
||||
key={index}
|
||||
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
|
||||
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}>
|
||||
<MoreHorizontal size={24} />
|
||||
</Button>
|
||||
|
||||
@@ -339,6 +339,9 @@ export default {
|
||||
MuteListNotFoundConfirmation:
|
||||
'لم يتم العثور على قائمة الكتم. هل تريد إنشاء واحدة جديدة؟ إذا كنت قد كتمت مستخدمين من قبل، يرجى عدم التأكيد لأن هذه العملية ستؤدي إلى فقدان قائمة الكتم السابقة.',
|
||||
'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.':
|
||||
|
||||
@@ -346,6 +346,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': 'Zu viele Schreib-Relays',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -340,6 +340,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': 'Too many write relays',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -345,6 +345,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': 'Demasiados relés de escritura',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -340,6 +340,9 @@ export default {
|
||||
MuteListNotFoundConfirmation:
|
||||
'فهرست بیصدا شدهها پیدا نشد. آیا میخواهید یکی جدید ایجاد کنید؟ اگر قبلاً کاربرانی را بیصدا کردهاید، لطفاً تأیید نکنید زیرا این عملیات باعث از دست رفتن فهرست بیصدا شدههای قبلی شما خواهد شد.',
|
||||
'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.':
|
||||
|
||||
@@ -345,6 +345,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': "Trop de relais d'écriture",
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -344,6 +344,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': 'Troppi relay di scrittura',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -342,6 +342,9 @@ export default {
|
||||
MuteListNotFoundConfirmation:
|
||||
'ミュートリストが見つかりません。新しいものを作成しますか?以前にユーザーをミュートしたことがある場合は、この操作により前のミュートリストが失われるため、確認しないでください。',
|
||||
'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 write relays': '書き込みリレイが多すぎます',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -341,6 +341,9 @@ export default {
|
||||
MuteListNotFoundConfirmation:
|
||||
'음소거 목록을 찾을 수 없습니다. 새로 만드시겠습니까? 이전에 사용자를 음소거한 적이 있다면 이 작업으로 인해 이전 음소거 목록을 잃게 되므로 확인하지 마시기 바랍니다.',
|
||||
'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 write relays': '쓰기 릴레이가 너무 많습니다',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -344,6 +344,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': 'Too many write relays',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -343,6 +343,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': 'Muitos relays de escrita',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -344,6 +344,9 @@ export default {
|
||||
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.',
|
||||
'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 write relays': 'Demasiados relays de escrita',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -344,6 +344,9 @@ export default {
|
||||
MuteListNotFoundConfirmation:
|
||||
'Список заблокированных не найден. Хотите создать новый? Если вы уже блокировали пользователей ранее, пожалуйста, НЕ подтверждайте, так как эта операция приведет к потере вашего предыдущего списка заблокированных.',
|
||||
'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 write relays': 'Слишком много релеев для записи',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -338,6 +338,9 @@ export default {
|
||||
MuteListNotFoundConfirmation:
|
||||
'ไม่พบรายการปิดเสียง คุณต้องการสร้างรายการใหม่หรือไม่? หากคุณเคยปิดเสียงผู้ใช้มาก่อน กรุณาอย่ายืนยัน เพราะการดำเนินการนี้จะทำให้คุณสูญเสียรายการปิดเสียงก่อนหน้านี้',
|
||||
'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.':
|
||||
|
||||
@@ -337,6 +337,8 @@ export default {
|
||||
MuteListNotFoundConfirmation:
|
||||
'未找到屏蔽列表。你想创建一个新的吗?如果你之前已经屏蔽了用户,请不要确认,因为此操作会导致你丢失之前的屏蔽列表。',
|
||||
'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 write relays': '写入中继过多',
|
||||
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
|
||||
import client from '@/services/client.service'
|
||||
import customEmojiService from '@/services/custom-emoji.service'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import {
|
||||
TDraftEvent,
|
||||
@@ -78,14 +79,15 @@ export async function createShortTextNoteDraftEvent(
|
||||
isNsfw?: boolean
|
||||
} = {}
|
||||
): Promise<TDraftEvent> {
|
||||
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
|
||||
const { quoteEventHexIds, quoteReplaceableCoordinates, rootETag, parentETag } =
|
||||
await extractRelatedEventIds(content, options.parentEvent)
|
||||
const hashtags = extractHashtags(content)
|
||||
await extractRelatedEventIds(transformedEmojisContent, options.parentEvent)
|
||||
const hashtags = extractHashtags(transformedEmojisContent)
|
||||
|
||||
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
|
||||
const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
|
||||
|
||||
// imeta tags
|
||||
const images = extractImagesFromContent(content)
|
||||
const images = extractImagesFromContent(transformedEmojisContent)
|
||||
if (images && images.length) {
|
||||
tags.push(...generateImetaTags(images))
|
||||
}
|
||||
@@ -120,7 +122,7 @@ export async function createShortTextNoteDraftEvent(
|
||||
|
||||
const baseDraft = {
|
||||
kind: kinds.ShortTextNote,
|
||||
content,
|
||||
content: transformedEmojisContent,
|
||||
tags
|
||||
}
|
||||
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()
|
||||
export async function createCommentDraftEvent(
|
||||
content: string,
|
||||
@@ -197,6 +161,7 @@ export async function createCommentDraftEvent(
|
||||
isNsfw?: boolean
|
||||
} = {}
|
||||
): Promise<TDraftEvent> {
|
||||
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
|
||||
const {
|
||||
quoteEventHexIds,
|
||||
quoteReplaceableCoordinates,
|
||||
@@ -205,15 +170,15 @@ export async function createCommentDraftEvent(
|
||||
rootKind,
|
||||
rootPubkey,
|
||||
rootUrl
|
||||
} = await extractCommentMentions(content, parentEvent)
|
||||
const hashtags = extractHashtags(content)
|
||||
} = await extractCommentMentions(transformedEmojisContent, parentEvent)
|
||||
const hashtags = extractHashtags(transformedEmojisContent)
|
||||
|
||||
const tags = hashtags
|
||||
.map((hashtag) => buildTTag(hashtag))
|
||||
const tags = emojiTags
|
||||
.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
|
||||
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
|
||||
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
|
||||
|
||||
const images = extractImagesFromContent(content)
|
||||
const images = extractImagesFromContent(transformedEmojisContent)
|
||||
if (images && images.length) {
|
||||
tags.push(...generateImetaTags(images))
|
||||
}
|
||||
@@ -260,7 +225,7 @@ export async function createCommentDraftEvent(
|
||||
|
||||
const baseDraft = {
|
||||
kind: ExtendedKind.COMMENT,
|
||||
content,
|
||||
content: transformedEmojisContent,
|
||||
tags
|
||||
}
|
||||
const cacheKey = JSON.stringify(baseDraft)
|
||||
@@ -374,13 +339,15 @@ export async function createPollDraftEvent(
|
||||
isNsfw?: boolean
|
||||
} = {}
|
||||
): Promise<TDraftEvent> {
|
||||
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(question)
|
||||
const hashtags = extractHashtags(question)
|
||||
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(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
|
||||
const images = extractImagesFromContent(question)
|
||||
const images = extractImagesFromContent(transformedEmojisContent)
|
||||
if (images && images.length) {
|
||||
tags.push(...generateImetaTags(images))
|
||||
}
|
||||
@@ -418,7 +385,7 @@ export async function createPollDraftEvent(
|
||||
}
|
||||
|
||||
const baseDraft = {
|
||||
content: question.trim(),
|
||||
content: transformedEmojisContent.trim(),
|
||||
kind: ExtendedKind.POLL,
|
||||
tags
|
||||
}
|
||||
@@ -583,6 +550,29 @@ function extractImagesFromContent(content: string) {
|
||||
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) {
|
||||
const coordinate = getReplaceableCoordinateFromEvent(event)
|
||||
const hint = client.getEventHint(event.id)
|
||||
@@ -661,10 +651,6 @@ function buildServerTag(url: string) {
|
||||
return ['server', url]
|
||||
}
|
||||
|
||||
function buildImetaTag(nip94Tags: string[][]) {
|
||||
return ['imeta', ...nip94Tags.map(([n, v]) => `${n} ${v}`)]
|
||||
}
|
||||
|
||||
function buildResponseTag(value: string) {
|
||||
return ['response', value]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { buildATag } from './draft-event'
|
||||
import { getReplaceableEventIdentifier } from './event'
|
||||
@@ -336,3 +336,36 @@ export function getPollResponseFromEvent(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import customEmojiService from '@/services/custom-emoji.service'
|
||||
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
|
||||
import { JSONContent } from '@tiptap/react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
@@ -38,7 +40,18 @@ function _parseEditorJsonToText(node?: JSONContent): string {
|
||||
return '\n'
|
||||
case 'mention':
|
||||
return node.attrs ? `nostr:${node.attrs.id}` : ''
|
||||
case 'emoji':
|
||||
return parseEmojiNodeName(node.attrs?.name)
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function parseEmojiNodeName(name?: string): string {
|
||||
if (!name) return ''
|
||||
if (customEmojiService.isCustomEmojiId(name)) {
|
||||
return `:${name}:`
|
||||
}
|
||||
const emoji = shortcodeToEmoji(name, emojis)
|
||||
return emoji ? (emoji.emoji ?? '') : ''
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
URL_REGEX,
|
||||
WS_URL_REGEX
|
||||
} from '@/constants'
|
||||
import { TEmoji } from '@/types'
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { parseNativeEmoji } from 'emoji-picker-react/src/dataUtils/parseNativeEmoji'
|
||||
import { franc } from 'franc-min'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -133,3 +135,16 @@ export function detectLanguage(text?: string): string | null {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { SelectValue } from '@radix-ui/react-select'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { forwardRef, HTMLProps, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -81,6 +82,22 @@ 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>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getLatestEvent, getReplaceableEventIdentifier } from '@/lib/event'
|
||||
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
|
||||
import { formatPubkey, isValidPubkey, pubkeyToNpub } from '@/lib/pubkey'
|
||||
import client from '@/services/client.service'
|
||||
import customEmojiService from '@/services/custom-emoji.service'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
@@ -43,6 +44,7 @@ type TNostrContext = {
|
||||
muteListEvent: Event | null
|
||||
bookmarkListEvent: Event | null
|
||||
favoriteRelaysEvent: Event | null
|
||||
userEmojiListEvent: Event | null
|
||||
notificationsSeenAt: number
|
||||
account: TAccountPointer | null
|
||||
accounts: TAccountPointer[]
|
||||
@@ -104,6 +106,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const [muteListEvent, setMuteListEvent] = useState<Event | null>(null)
|
||||
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
|
||||
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
|
||||
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
|
||||
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
@@ -173,14 +176,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
storedFollowListEvent,
|
||||
storedMuteListEvent,
|
||||
storedBookmarkListEvent,
|
||||
storedFavoriteRelaysEvent
|
||||
storedFavoriteRelaysEvent,
|
||||
storedUserEmojiListEvent
|
||||
] = await Promise.all([
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist),
|
||||
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) {
|
||||
setRelayList(getRelayListFromEvent(storedRelayListEvent))
|
||||
@@ -201,6 +206,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
if (storedFavoriteRelaysEvent) {
|
||||
setFavoriteRelaysEvent(storedFavoriteRelaysEvent)
|
||||
}
|
||||
if (storedUserEmojiListEvent) {
|
||||
setUserEmojiListEvent(storedUserEmojiListEvent)
|
||||
}
|
||||
|
||||
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
|
||||
kinds: [kinds.RelayList],
|
||||
@@ -222,7 +230,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
kinds.Mutelist,
|
||||
kinds.BookmarkList,
|
||||
ExtendedKind.FAVORITE_RELAYS,
|
||||
ExtendedKind.BLOSSOM_SERVER_LIST
|
||||
ExtendedKind.BLOSSOM_SERVER_LIST,
|
||||
kinds.UserEmojiList
|
||||
],
|
||||
authors: [account.pubkey]
|
||||
},
|
||||
@@ -241,15 +250,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const blossomServerListEvent = sortedEvents.find(
|
||||
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
|
||||
)
|
||||
const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList)
|
||||
const notificationsSeenAtEvent = sortedEvents.find(
|
||||
(e) =>
|
||||
e.kind === kinds.Application &&
|
||||
getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT
|
||||
)
|
||||
if (profileEvent) {
|
||||
setProfileEvent(profileEvent)
|
||||
setProfile(getProfileFromEvent(profileEvent))
|
||||
await indexedDb.putReplaceableEvent(profileEvent)
|
||||
const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
|
||||
if (updatedProfileEvent.id === profileEvent.id) {
|
||||
setProfileEvent(updatedProfileEvent)
|
||||
setProfile(getProfileFromEvent(updatedProfileEvent))
|
||||
}
|
||||
} else if (!storedProfileEvent) {
|
||||
setProfile({
|
||||
pubkey: account.pubkey,
|
||||
@@ -258,24 +270,38 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
}
|
||||
if (followListEvent) {
|
||||
const updatedFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent)
|
||||
if (updatedFollowListEvent.id === followListEvent.id) {
|
||||
setFollowListEvent(followListEvent)
|
||||
await indexedDb.putReplaceableEvent(followListEvent)
|
||||
}
|
||||
}
|
||||
if (muteListEvent) {
|
||||
const updatedMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
|
||||
if (updatedMuteListEvent.id === muteListEvent.id) {
|
||||
setMuteListEvent(muteListEvent)
|
||||
await indexedDb.putReplaceableEvent(muteListEvent)
|
||||
}
|
||||
}
|
||||
if (bookmarkListEvent) {
|
||||
const updateBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent)
|
||||
if (updateBookmarkListEvent.id === bookmarkListEvent.id) {
|
||||
setBookmarkListEvent(bookmarkListEvent)
|
||||
await indexedDb.putReplaceableEvent(bookmarkListEvent)
|
||||
}
|
||||
}
|
||||
if (favoriteRelaysEvent) {
|
||||
setFavoriteRelaysEvent(favoriteRelaysEvent)
|
||||
await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
|
||||
const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
|
||||
if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) {
|
||||
setFavoriteRelaysEvent(updatedFavoriteRelaysEvent)
|
||||
}
|
||||
}
|
||||
if (blossomServerListEvent) {
|
||||
await client.updateBlossomServerListEventCache(blossomServerListEvent)
|
||||
}
|
||||
if (userEmojiListEvent) {
|
||||
const updatedUserEmojiListEvent = await indexedDb.putReplaceableEvent(userEmojiListEvent)
|
||||
if (updatedUserEmojiListEvent.id === userEmojiListEvent.id) {
|
||||
setUserEmojiListEvent(updatedUserEmojiListEvent)
|
||||
}
|
||||
}
|
||||
|
||||
const notificationsSeenAt = Math.max(
|
||||
notificationsSeenAtEvent?.created_at ?? 0,
|
||||
@@ -334,6 +360,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [account])
|
||||
|
||||
useEffect(() => {
|
||||
customEmojiService.init(userEmojiListEvent)
|
||||
}, [userEmojiListEvent])
|
||||
|
||||
const hasNostrLoginHash = () => {
|
||||
return window.location.hash && window.location.hash.startsWith('#nostr-login')
|
||||
}
|
||||
@@ -734,6 +764,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
muteListEvent,
|
||||
bookmarkListEvent,
|
||||
favoriteRelaysEvent,
|
||||
userEmojiListEvent,
|
||||
notificationsSeenAt,
|
||||
account,
|
||||
accounts,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import {
|
||||
compareEvents,
|
||||
getLatestEvent,
|
||||
getReplaceableCoordinate,
|
||||
getReplaceableCoordinateFromEvent,
|
||||
isReplaceableEvent
|
||||
@@ -10,6 +9,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata
|
||||
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
|
||||
import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag'
|
||||
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||
import { isSafari } from '@/lib/utils'
|
||||
import { ISigner, TProfile, TRelayList, TSubRequestFilter } from '@/types'
|
||||
import { sha256 } from '@noble/hashes/sha2'
|
||||
import DataLoader from 'dataloader'
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from 'nostr-tools'
|
||||
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||
import indexedDb from './indexed-db.service'
|
||||
import { isSafari } from '@/lib/utils'
|
||||
|
||||
type TTimelineRef = [string, number]
|
||||
|
||||
@@ -1094,47 +1093,78 @@ class ClientService extends EventTarget {
|
||||
/** =========== Replaceable event dataloader =========== */
|
||||
|
||||
private replaceableEventDataLoader = new DataLoader<
|
||||
{ pubkey: string; kind: number },
|
||||
{ pubkey: string; kind: number; d?: string },
|
||||
NEvent | null,
|
||||
string
|
||||
>(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 }[]) {
|
||||
const results = await Promise.allSettled(
|
||||
params.map(async ({ pubkey, kind }) => {
|
||||
const relayList = await this.fetchRelayList(pubkey)
|
||||
const events = await this.query(relayList.write.concat(BIG_RELAY_URLS).slice(0, 5), {
|
||||
authors: [pubkey],
|
||||
kinds: [kind]
|
||||
})
|
||||
const event = getLatestEvent(events) ?? null
|
||||
if (event) {
|
||||
indexedDb.putReplaceableEvent(event)
|
||||
} else {
|
||||
indexedDb.putNullReplaceableEvent(pubkey, kind)
|
||||
private async replaceableEventBatchLoadFn(
|
||||
params: readonly { pubkey: string; kind: number; d?: string }[]
|
||||
) {
|
||||
const groups = new Map<string, { kind: number; d?: string }[]>()
|
||||
params.forEach(({ pubkey, kind, d }) => {
|
||||
if (!groups.has(pubkey)) {
|
||||
groups.set(pubkey, [])
|
||||
}
|
||||
groups.get(pubkey)!.push({ kind: kind, d })
|
||||
})
|
||||
|
||||
const eventMap = new Map<string, NEvent | null>()
|
||||
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 result.value
|
||||
|
||||
return params.map(({ pubkey, kind, d }) => {
|
||||
const key = `${kind}:${pubkey}:${d ?? ''}`
|
||||
const event = eventMap.get(key)
|
||||
if (event) {
|
||||
indexedDb.putReplaceableEvent(event)
|
||||
return event
|
||||
} else {
|
||||
console.error('Failed to load replaceable event:', result.reason)
|
||||
indexedDb.putNullReplaceableEvent(pubkey, kind, d)
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchReplaceableEvent(pubkey: string, kind: number) {
|
||||
const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind)
|
||||
private async fetchReplaceableEvent(pubkey: string, kind: number, d?: string) {
|
||||
const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind, d)
|
||||
if (storedEvent !== undefined) {
|
||||
return storedEvent
|
||||
}
|
||||
|
||||
return await this.replaceableEventDataLoader.load({ pubkey, kind })
|
||||
return await this.replaceableEventDataLoader.load({ pubkey, kind, d })
|
||||
}
|
||||
|
||||
private async updateReplaceableEventCache(event: NEvent) {
|
||||
@@ -1182,6 +1212,21 @@ class ClientService extends EventTarget {
|
||||
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 =================
|
||||
|
||||
async generateSubRequestsForPubkeys(pubkeys: string[], myPubkey?: string | null) {
|
||||
|
||||
118
src/services/custom-emoji.service.ts
Normal file
118
src/services/custom-emoji.service.ts
Normal 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
|
||||
@@ -17,6 +17,8 @@ const StoreNames = {
|
||||
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
|
||||
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
|
||||
RELAY_INFO_EVENTS: 'relayInfoEvents',
|
||||
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
|
||||
EMOJI_SET_EVENTS: 'emojiSetEvents',
|
||||
FAVORITE_RELAYS: 'favoriteRelays',
|
||||
RELAY_SETS: 'relaySets',
|
||||
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays'
|
||||
@@ -38,7 +40,7 @@ class IndexedDbService {
|
||||
init(): Promise<void> {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = new Promise((resolve, reject) => {
|
||||
const request = window.indexedDB.open('jumble', 6)
|
||||
const request = window.indexedDB.open('jumble', 7)
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(event)
|
||||
@@ -84,6 +86,12 @@ class IndexedDbService {
|
||||
if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) {
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -92,7 +100,7 @@ class IndexedDbService {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
async putNullReplaceableEvent(pubkey: string, kind: number) {
|
||||
async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) {
|
||||
const storeName = this.getStoreNameByKind(kind)
|
||||
if (!storeName) {
|
||||
return Promise.reject('store name not found')
|
||||
@@ -105,14 +113,15 @@ class IndexedDbService {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite')
|
||||
const store = transaction.objectStore(storeName)
|
||||
|
||||
const getRequest = store.get(pubkey)
|
||||
const key = this.getReplaceableEventKey(pubkey, d)
|
||||
const getRequest = store.get(key)
|
||||
getRequest.onsuccess = () => {
|
||||
const oldValue = getRequest.result as TValue<Event> | undefined
|
||||
if (oldValue) {
|
||||
transaction.commit()
|
||||
return resolve(oldValue.value)
|
||||
}
|
||||
const putRequest = store.put(this.formatValue(pubkey, null))
|
||||
const putRequest = store.put(this.formatValue(key, null))
|
||||
putRequest.onsuccess = () => {
|
||||
transaction.commit()
|
||||
resolve(null)
|
||||
@@ -144,7 +153,7 @@ class IndexedDbService {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite')
|
||||
const store = transaction.objectStore(storeName)
|
||||
|
||||
const key = this.getReplaceableEventKey(event)
|
||||
const key = this.getReplaceableEventKeyFromEvent(event)
|
||||
const getRequest = store.get(key)
|
||||
getRequest.onsuccess = () => {
|
||||
const oldValue = getRequest.result as TValue<Event> | undefined
|
||||
@@ -187,7 +196,7 @@ class IndexedDbService {
|
||||
}
|
||||
const transaction = this.db.transaction(storeName, 'readonly')
|
||||
const store = transaction.objectStore(storeName)
|
||||
const key = d === undefined ? pubkey : `${pubkey}:${d}`
|
||||
const key = this.getReplaceableEventKey(pubkey, d)
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
@@ -220,7 +229,7 @@ class IndexedDbService {
|
||||
const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined)
|
||||
let count = 0
|
||||
pubkeys.forEach((pubkey, i) => {
|
||||
const request = store.get(pubkey)
|
||||
const request = store.get(this.getReplaceableEventKey(pubkey))
|
||||
|
||||
request.onsuccess = () => {
|
||||
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 (
|
||||
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
|
||||
(event.kind >= 10000 && event.kind < 20000)
|
||||
) {
|
||||
return event.pubkey
|
||||
return this.getReplaceableEventKey(event.pubkey)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -445,6 +458,10 @@ class IndexedDbService {
|
||||
return StoreNames.FAVORITE_RELAYS
|
||||
case kinds.BookmarkList:
|
||||
return StoreNames.BOOKMARK_LIST_EVENTS
|
||||
case kinds.UserEmojiList:
|
||||
return StoreNames.USER_EMOJI_LIST_EVENTS
|
||||
case kinds.Emojisets:
|
||||
return StoreNames.EMOJI_SET_EVENTS
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user