feat: custom emoji

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

View file

@ -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" />

View file

@ -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>

View file

@ -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)
}}

View file

@ -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()}
/>
)
}

View file

@ -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>

View file

@ -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)
}}
/>
) : (

View file

@ -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>
))}

View file

@ -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>

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,9 @@ import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { 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
}

View file

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

View file

@ -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)

View file

@ -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>
)
}

View file

@ -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()
}
}
}
}))

View file

@ -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>

View file

@ -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) => (
<div
key={index}
className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl"
onClick={() => onEmojiClick(parseNativeEmoji(emoji))}
>
{parseNativeEmoji(emoji)}
</div>
))}
{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(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>