46 changed files with 885 additions and 176 deletions
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -1,12 +1,21 @@ |
|||||||
import { Card } from '@/components/ui/card' |
import { Card } from '@/components/ui/card' |
||||||
|
import { transformCustomEmojisInContent } from '@/lib/draft-event' |
||||||
import { createFakeEvent } from '@/lib/event' |
import { createFakeEvent } from '@/lib/event' |
||||||
import { cn } from '@/lib/utils' |
import { cn } from '@/lib/utils' |
||||||
|
import { useMemo } from 'react' |
||||||
import Content from '../../Content' |
import Content from '../../Content' |
||||||
|
|
||||||
export default function Preview({ content, className }: { content: string; className?: string }) { |
export default function Preview({ content, className }: { content: string; className?: string }) { |
||||||
|
const { content: processedContent, emojiTags } = useMemo( |
||||||
|
() => transformCustomEmojisInContent(content), |
||||||
|
[content] |
||||||
|
) |
||||||
return ( |
return ( |
||||||
<Card className={cn('p-3', className)}> |
<Card className={cn('p-3', className)}> |
||||||
<Content event={createFakeEvent({ content })} className="pointer-events-none h-full" /> |
<Content |
||||||
|
event={createFakeEvent({ content: processedContent, tags: emojiTags })} |
||||||
|
className="pointer-events-none h-full" |
||||||
|
/> |
||||||
</Card> |
</Card> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,118 @@ |
|||||||
|
import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata' |
||||||
|
import { parseEmojiPickerUnified } from '@/lib/utils' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { TEmoji } from '@/types' |
||||||
|
import { sha256 } from '@noble/hashes/sha2' |
||||||
|
import { SkinTones } from 'emoji-picker-react' |
||||||
|
import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested' |
||||||
|
import FlexSearch from 'flexsearch' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
class CustomEmojiService { |
||||||
|
static instance: CustomEmojiService |
||||||
|
|
||||||
|
private emojiMap = new Map<string, TEmoji>() |
||||||
|
private emojiIndex = new FlexSearch.Index({ |
||||||
|
tokenize: 'full' |
||||||
|
}) |
||||||
|
|
||||||
|
constructor() { |
||||||
|
if (!CustomEmojiService.instance) { |
||||||
|
CustomEmojiService.instance = this |
||||||
|
} |
||||||
|
return CustomEmojiService.instance |
||||||
|
} |
||||||
|
|
||||||
|
async init(userEmojiListEvent: Event | null) { |
||||||
|
if (!userEmojiListEvent) return |
||||||
|
|
||||||
|
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(userEmojiListEvent) |
||||||
|
await this.addEmojisToIndex(emojis) |
||||||
|
|
||||||
|
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers) |
||||||
|
await Promise.allSettled( |
||||||
|
emojiSetEvents.map(async (event) => { |
||||||
|
if (!event || event instanceof Error) return |
||||||
|
|
||||||
|
await this.addEmojisToIndex(getEmojisFromEvent(event)) |
||||||
|
}) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
async searchEmojis(query: string = ''): Promise<string[]> { |
||||||
|
if (!query) { |
||||||
|
const idSet = new Set<string>() |
||||||
|
getSuggested() |
||||||
|
.sort((a, b) => b.count - a.count) |
||||||
|
.map((item) => parseEmojiPickerUnified(item.unified)) |
||||||
|
.forEach((item) => { |
||||||
|
if (item && typeof item !== 'string') { |
||||||
|
const id = this.getEmojiId(item) |
||||||
|
if (!idSet.has(id)) { |
||||||
|
idSet.add(id) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
for (const key of this.emojiMap.keys()) { |
||||||
|
idSet.add(key) |
||||||
|
} |
||||||
|
return Array.from(idSet) |
||||||
|
} |
||||||
|
const results = await this.emojiIndex.searchAsync(query) |
||||||
|
return results.filter((id) => typeof id === 'string') as string[] |
||||||
|
} |
||||||
|
|
||||||
|
getEmojiById(id?: string): TEmoji | undefined { |
||||||
|
if (!id) return undefined |
||||||
|
|
||||||
|
return this.emojiMap.get(id) |
||||||
|
} |
||||||
|
|
||||||
|
getAllCustomEmojisForPicker() { |
||||||
|
return Array.from(this.emojiMap.values()).map((emoji) => ({ |
||||||
|
id: `:${emoji.shortcode}:${emoji.url}`, |
||||||
|
imgUrl: emoji.url, |
||||||
|
names: [emoji.shortcode] |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
isCustomEmojiId(shortcode: string) { |
||||||
|
return this.emojiMap.has(shortcode) |
||||||
|
} |
||||||
|
|
||||||
|
private async addEmojisToIndex(emojis: TEmoji[]) { |
||||||
|
await Promise.allSettled( |
||||||
|
emojis.map(async (emoji) => { |
||||||
|
const id = this.getEmojiId(emoji) |
||||||
|
this.emojiMap.set(id, emoji) |
||||||
|
await this.emojiIndex.addAsync(id, emoji.shortcode) |
||||||
|
}) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
getEmojiId(emoji: TEmoji) { |
||||||
|
const encoder = new TextEncoder() |
||||||
|
const data = encoder.encode(`${emoji.shortcode}:${emoji.url}`.toLowerCase()) |
||||||
|
const hashBuffer = sha256(data) |
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer)) |
||||||
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') |
||||||
|
} |
||||||
|
|
||||||
|
updateSuggested(id: string) { |
||||||
|
const emoji = this.getEmojiById(id) |
||||||
|
if (!emoji) return |
||||||
|
|
||||||
|
setSuggested( |
||||||
|
{ |
||||||
|
n: [emoji.shortcode.toLowerCase()], |
||||||
|
u: `:${emoji.shortcode}:${emoji.url}`.toLowerCase(), |
||||||
|
a: '0', |
||||||
|
imgUrl: emoji.url |
||||||
|
}, |
||||||
|
SkinTones.NEUTRAL |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const instance = new CustomEmojiService() |
||||||
|
export default instance |
||||||
Loading…
Reference in new issue