From 71d4420604e8a13029709df49067d138d3419d4a Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 22 Aug 2025 21:05:44 +0800 Subject: [PATCH] feat: custom emoji --- package-lock.json | 55 ++++++++ package.json | 1 + src/components/Content/index.tsx | 2 +- src/components/ContentPreview/Content.tsx | 2 +- src/components/Emoji/index.tsx | 4 +- src/components/EmojiPicker/index.tsx | 16 ++- src/components/EmojiPickerDialog/index.tsx | 11 +- src/components/NoteStats/LikeButton.tsx | 19 +-- src/components/NoteStats/Likes.tsx | 6 +- src/components/PostEditor/PostContent.tsx | 7 +- .../PostTextarea/Emoji/EmojiList.tsx | 131 ++++++++++++++++++ .../PostTextarea/Emoji/EmojiNode.tsx | 33 +++++ .../PostEditor/PostTextarea/Emoji/index.tsx | 12 ++ .../PostTextarea/Emoji/suggestion.ts | 100 +++++++++++++ .../{ => Mention}/MentionList.tsx | 8 +- .../{ => Mention}/MentionNode.tsx | 0 .../{CustomMention.ts => Mention/index.ts} | 6 +- .../PostTextarea/{ => Mention}/suggestion.ts | 17 ++- .../PostEditor/PostTextarea/Preview.tsx | 11 +- .../PostEditor/PostTextarea/index.tsx | 28 +++- src/components/ReactionList/index.tsx | 3 +- src/components/SuggestedEmojis/index.tsx | 56 ++++---- src/i18n/locales/ar.ts | 3 + src/i18n/locales/de.ts | 3 + src/i18n/locales/en.ts | 3 + src/i18n/locales/es.ts | 3 + src/i18n/locales/fa.ts | 3 + src/i18n/locales/fr.ts | 3 + src/i18n/locales/it.ts | 3 + src/i18n/locales/ja.ts | 3 + src/i18n/locales/ko.ts | 3 + src/i18n/locales/pl.ts | 3 + src/i18n/locales/pt-BR.ts | 3 + src/i18n/locales/pt-PT.ts | 3 + src/i18n/locales/ru.ts | 3 + src/i18n/locales/th.ts | 3 + src/i18n/locales/zh.ts | 2 + src/lib/draft-event.ts | 102 ++++++-------- src/lib/event-metadata.ts | 35 ++++- src/lib/tiptap.ts | 13 ++ src/lib/utils.ts | 15 ++ .../secondary/GeneralSettingsPage/index.tsx | 17 +++ src/providers/NostrProvider/index.tsx | 59 ++++++-- src/services/client.service.ts | 93 +++++++++---- src/services/custom-emoji.service.ts | 118 ++++++++++++++++ src/services/indexed-db.service.ts | 37 +++-- 46 files changed, 885 insertions(+), 176 deletions(-) create mode 100644 src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx create mode 100644 src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx create mode 100644 src/components/PostEditor/PostTextarea/Emoji/index.tsx create mode 100644 src/components/PostEditor/PostTextarea/Emoji/suggestion.ts rename src/components/PostEditor/PostTextarea/{ => Mention}/MentionList.tsx (94%) rename src/components/PostEditor/PostTextarea/{ => Mention}/MentionNode.tsx (100%) rename src/components/PostEditor/PostTextarea/{CustomMention.ts => Mention/index.ts} (93%) rename src/components/PostEditor/PostTextarea/{ => Mention}/suggestion.ts (91%) create mode 100644 src/services/custom-emoji.service.ts diff --git a/package-lock.json b/package-lock.json index 776999c..780f3a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9611883..0d94231 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index b9022d0..baeb4a2 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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 + return } if (node.type === 'youtube') { return diff --git a/src/components/ContentPreview/Content.tsx b/src/components/ContentPreview/Content.tsx index dd4de29..371d046 100644 --- a/src/components/ContentPreview/Content.tsx +++ b/src/components/ContentPreview/Content.tsx @@ -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 + return } })} diff --git a/src/components/Emoji/index.tsx b/src/components/Emoji/index.tsx index c936168..00501c4 100644 --- a/src/components/Emoji/index.tsx +++ b/src/components/Emoji/index.tsx @@ -17,7 +17,7 @@ export default function Emoji({ if (typeof emoji === 'string') { return emoji === '+' ? ( - + ) : ( {emoji} ) @@ -33,7 +33,7 @@ export default function Emoji({ {emoji.shortcode} { setHasError(false) }} diff --git a/src/components/EmojiPicker/index.tsx b/src/components/EmojiPicker/index.tsx index e0c8354..9935384 100644 --- a/src/components/EmojiPicker/index.tsx +++ b/src/components/EmojiPicker/index.tsx @@ -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()} /> ) } diff --git a/src/components/EmojiPickerDialog/index.tsx b/src/components/EmojiPickerDialog/index.tsx index 1e59ece..8bc068a 100644 --- a/src/components/EmojiPickerDialog/index.tsx +++ b/src/components/EmojiPickerDialog/index.tsx @@ -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({ {children} { + onEmojiClick={(emoji, e) => { e.stopPropagation() setOpen(false) - onEmojiClick?.(data.emoji) + onEmojiClick?.(emoji) }} /> @@ -40,10 +41,10 @@ export default function EmojiPickerDialog({ {children} { + onEmojiClick={(emoji, e) => { e.stopPropagation() setOpen(false) - onEmojiClick?.(data.emoji) + onEmojiClick?.(emoji) }} /> diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 58da930..2d56275 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -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 }) { ) : myLastEmoji ? ( <> -
- -
+ {!!likeCount &&
{formatCount(likeCount)}
} ) : ( @@ -97,9 +96,11 @@ export default function LikeButton({ event }: { event: Event }) { setIsEmojiReactionsOpen(false)} /> { + onEmojiClick={(emoji) => { setIsEmojiReactionsOpen(false) - like(data.emoji) + if (!emoji) return + + like(emoji) }} /> @@ -122,10 +123,12 @@ export default function LikeButton({ event }: { event: Event }) { {isPickerOpen ? ( { + onEmojiClick={(emoji, e) => { e.stopPropagation() setIsEmojiReactionsOpen(false) - like(data.emoji) + if (!emoji) return + + like(emoji) }} /> ) : ( diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index 2fdd7db..5395c5c 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -71,7 +71,11 @@ export default function Likes({ event }: { event: Event }) { like(key, emoji) }} > - {liking === key ? : } + {liking === key ? ( + + ) : ( + + )}
{pubkeys.size}
))} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d046b8d..9720a13 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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() && ( - textareaRef.current?.insertText(emoji)}> + { + if (!emoji) return + textareaRef.current?.insertEmoji(emoji) + }} + > diff --git a/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx b/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx new file mode 100644 index 0000000..8f05f03 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx @@ -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((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 ( + e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > +
+ {props.items.map((item, index) => { + return ( + + ) + })} +
+
+ ) +}) + +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 ( + + ) +} diff --git a/src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx b/src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx new file mode 100644 index 0000000..bbb7e43 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Emoji/EmojiNode.tsx @@ -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 ( + + {emoji} + + ) + } + + return ( + + + + ) +} diff --git a/src/components/PostEditor/PostTextarea/Emoji/index.tsx b/src/components/PostEditor/PostTextarea/Emoji/index.tsx new file mode 100644 index 0000000..6731c75 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Emoji/index.tsx @@ -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 diff --git a/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts new file mode 100644 index 0000000..0ff704d --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts @@ -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 | 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) + }, + + 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 diff --git a/src/components/PostEditor/PostTextarea/MentionList.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx similarity index 94% rename from src/components/PostEditor/PostTextarea/MentionList.tsx rename to src/components/PostEditor/PostTextarea/Mention/MentionList.tsx index 6c32371..f504098 100644 --- a/src/components/PostEditor/PostTextarea/MentionList.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx @@ -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((props, ref) } })) - if (props.items.length === 0) { + if (!props.items?.length) { return null } diff --git a/src/components/PostEditor/PostTextarea/MentionNode.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx similarity index 100% rename from src/components/PostEditor/PostTextarea/MentionNode.tsx rename to src/components/PostEditor/PostTextarea/Mention/MentionNode.tsx diff --git a/src/components/PostEditor/PostTextarea/CustomMention.ts b/src/components/PostEditor/PostTextarea/Mention/index.ts similarity index 93% rename from src/components/PostEditor/PostTextarea/CustomMention.ts rename to src/components/PostEditor/PostTextarea/Mention/index.ts index b0aeaa2..aef3ba2 100644 --- a/src/components/PostEditor/PostTextarea/CustomMention.ts +++ b/src/components/PostEditor/PostTextarea/Mention/index.ts @@ -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, diff --git a/src/components/PostEditor/PostTextarea/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts similarity index 91% rename from src/components/PostEditor/PostTextarea/suggestion.ts rename to src/components/PostEditor/PostTextarea/Mention/suggestion.ts index beb42aa..e0bdfb6 100644 --- a/src/components/PostEditor/PostTextarea/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -12,8 +12,8 @@ const suggestion = { }, render: () => { - let component: ReactRenderer - let popup: Instance[] + let component: ReactRenderer | 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) }, 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) diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx index ffc147a..09e7a4e 100644 --- a/src/components/PostEditor/PostTextarea/Preview.tsx +++ b/src/components/PostEditor/PostTextarea/Preview.tsx @@ -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 ( - + ) } diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 22a41f2..1973d3f 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -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() + } + } } })) diff --git a/src/components/ReactionList/index.tsx b/src/components/ReactionList/index.tsx index 4f8cae8..3b359f8 100644 --- a/src/components/ReactionList/index.tsx +++ b/src/components/ReactionList/index.tsx @@ -53,8 +53,7 @@ export default function ReactionList({ event }: { event: Event }) { diff --git a/src/components/SuggestedEmojis/index.tsx b/src/components/SuggestedEmojis/index.tsx index 0a5c734..145cca3 100644 --- a/src/components/SuggestedEmojis/index.tsx +++ b/src/components/SuggestedEmojis/index.tsx @@ -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([ - '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 (
e.stopPropagation()}> - {suggestedEmojis.map((emoji, index) => ( -
onEmojiClick(parseNativeEmoji(emoji))} - > - {parseNativeEmoji(emoji)} -
- ))} + {suggestedEmojis.map((emoji, index) => + typeof emoji === 'string' ? ( +
onEmojiClick(emoji)} + > + {emoji} +
+ ) : ( +
onEmojiClick(emoji)} + > + +
+ ) + )} diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index db550d1..bf7e526 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -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.': diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 0625004..a82a8aa 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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.': diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f82ba82..891aee9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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.': diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 9a8a2b1..6930e50 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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.': diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index 1fa1e2b..b6d165a 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -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.': diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 786e0c7..ba3915c 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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.': diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 494eaa8..71474a4 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -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.': diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 37544aa..cccaec7 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -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.': diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index f8744bf..10ba972 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -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.': diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 4cf1249..f4fb749 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -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.': diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 68bb2d6..aaba090 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -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.': diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 2d569df..72e0f7c 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -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.': diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e539259..5a3e88e 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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.': diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 5a0f07c..656d388 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -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.': diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index b7b7614..5dd8214 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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.': diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 977c859..e4b02d6 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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 { + 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): TDr } } -export async function createPictureNoteDraftEvent( - content: string, - pictureInfos: { url: string; tags: string[][] }[], - mentions: string[], - options: { - addClientTag?: boolean - protectedEvent?: boolean - } = {} -): Promise { - 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 = new Map() export async function createCommentDraftEvent( content: string, @@ -197,6 +161,7 @@ export async function createCommentDraftEvent( isNsfw?: boolean } = {} ): Promise { + 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 { - 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() + 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] } diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 442fe62..1a3e941 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -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 +} diff --git a/src/lib/tiptap.ts b/src/lib/tiptap.ts index abac6b7..566a9b1 100644 --- a/src/lib/tiptap.ts +++ b/src/lib/tiptap.ts @@ -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 ?? '') : '' +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7918cb4..44e51b9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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) + } +} diff --git a/src/pages/secondary/GeneralSettingsPage/index.tsx b/src/pages/secondary/GeneralSettingsPage/index.tsx index 2a40ccd..4ebfe51 100644 --- a/src/pages/secondary/GeneralSettingsPage/index.tsx +++ b/src/pages/secondary/GeneralSettingsPage/index.tsx @@ -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) => { + +
+ + {t('Custom emoji management')} + + +
+ {t('After changing emojis, you may need to refresh the page')} +
+
+
) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 97ff1f1..038fb76 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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(null) const [bookmarkListEvent, setBookmarkListEvent] = useState(null) const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState(null) + const [userEmojiListEvent, setUserEmojiListEvent] = useState(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) { - setFollowListEvent(followListEvent) - await indexedDb.putReplaceableEvent(followListEvent) + const updatedFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent) + if (updatedFollowListEvent.id === followListEvent.id) { + setFollowListEvent(followListEvent) + } } if (muteListEvent) { - setMuteListEvent(muteListEvent) - await indexedDb.putReplaceableEvent(muteListEvent) + const updatedMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent) + if (updatedMuteListEvent.id === muteListEvent.id) { + setMuteListEvent(muteListEvent) + } } if (bookmarkListEvent) { - setBookmarkListEvent(bookmarkListEvent) - await indexedDb.putReplaceableEvent(bookmarkListEvent) + const updateBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent) + if (updateBookmarkListEvent.id === bookmarkListEvent.id) { + setBookmarkListEvent(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, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index f7f85f5..5677e62 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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] + private async replaceableEventBatchLoadFn( + params: readonly { pubkey: string; kind: number; d?: string }[] + ) { + const groups = new Map() + params.forEach(({ pubkey, kind, d }) => { + if (!groups.has(pubkey)) { + groups.set(pubkey, []) + } + groups.get(pubkey)!.push({ kind: kind, d }) + }) + + const eventMap = new Map() + await Promise.allSettled( + Array.from(groups.entries()).map(async ([pubkey, _params]) => { + const groupByKind = new Map() + _params.forEach(({ kind, d }) => { + if (!groupByKind.has(kind)) { + groupByKind.set(kind, []) + } + if (d) { + groupByKind.get(kind)!.push(d) + } }) - const event = getLatestEvent(events) ?? null - if (event) { - indexedDb.putReplaceableEvent(event) - } else { - indexedDb.putNullReplaceableEvent(pubkey, kind) + 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) { diff --git a/src/services/custom-emoji.service.ts b/src/services/custom-emoji.service.ts new file mode 100644 index 0000000..d7f0811 --- /dev/null +++ b/src/services/custom-emoji.service.ts @@ -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() + 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 { + if (!query) { + const idSet = new Set() + 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 diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index c72b573..784bf26 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -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 { 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 | 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 | 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)?.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 }