From 31a53b9c486cc73c7eb8a602ccb38942ca12dbf0 Mon Sep 17 00:00:00 2001 From: reya Date: Sat, 25 Nov 2023 15:41:18 +0700 Subject: [PATCH] add @ suggestion popup --- src/app/new/components/index.ts | 1 + src/app/new/components/mentionList.tsx | 104 +++++++++++++++++++++++++ src/app/new/post.tsx | 12 +++ src/libs/storage/instance.ts | 15 ++++ src/utils/hooks/useSuggestion.ts | 76 ++++++++++++++++++ src/utils/types.d.ts | 4 + 6 files changed, 212 insertions(+) create mode 100644 src/app/new/components/mentionList.tsx create mode 100644 src/utils/hooks/useSuggestion.ts diff --git a/src/app/new/components/index.ts b/src/app/new/components/index.ts index 46777124..40960305 100644 --- a/src/app/new/components/index.ts +++ b/src/app/new/components/index.ts @@ -2,3 +2,4 @@ export * from './articleCoverUploader'; export * from './mediaUploader'; export * from './mentionPopup'; export * from './mentionPopupItem'; +export * from './mentionList'; diff --git a/src/app/new/components/mentionList.tsx b/src/app/new/components/mentionList.tsx new file mode 100644 index 00000000..f8e0a42a --- /dev/null +++ b/src/app/new/components/mentionList.tsx @@ -0,0 +1,104 @@ +import * as Avatar from '@radix-ui/react-avatar'; +import { minidenticon } from 'minidenticons'; +import { Ref, forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import { NDKCacheUserProfile } from '@utils/types'; + +type MentionListRef = { + onKeyDown: (props: { event: Event }) => boolean; +}; + +const List = ( + props: { + items: NDKCacheUserProfile[]; + command: (arg0: { id: string }) => void; + }, + ref: Ref +) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = (index) => { + const item = props.items[index]; + if (item) { + props.command({ id: item.pubkey }); + } + }; + + const upHandler = () => { + setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => setSelectedIndex(0), [props.items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === 'ArrowUp') { + upHandler(); + return true; + } + + if (event.key === 'ArrowDown') { + downHandler(); + return true; + } + + if (event.key === 'Enter') { + enterHandler(); + return true; + } + + return false; + }, + })); + + return ( +
+ {props.items.length ? ( + props.items.map((item, index) => ( + + )) + ) : ( +
No result
+ )} +
+ ); +}; + +export const MentionList = forwardRef(List); diff --git a/src/app/new/post.tsx b/src/app/new/post.tsx index 0c7c83c0..b3fc4bb6 100644 --- a/src/app/new/post.tsx +++ b/src/app/new/post.tsx @@ -1,10 +1,12 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import CharacterCount from '@tiptap/extension-character-count'; import Image from '@tiptap/extension-image'; +import Mention from '@tiptap/extension-mention'; import Placeholder from '@tiptap/extension-placeholder'; import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { convert } from 'html-to-text'; +import { nip19 } from 'nostr-tools'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; @@ -18,11 +20,13 @@ import { MentionNote } from '@shared/notes'; import { WIDGET_KIND } from '@stores/constants'; +import { useSuggestion } from '@utils/hooks/useSuggestion'; import { useWidget } from '@utils/hooks/useWidget'; export function NewPostScreen() { const { ndk } = useNDK(); const { addWidget } = useWidget(); + const { suggestion } = useSuggestion(); const [loading, setLoading] = useState(false); const [height, setHeight] = useState(0); @@ -41,6 +45,14 @@ export function NewPostScreen() { }, }), CharacterCount.configure(), + Mention.configure({ + suggestion, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + renderLabel({ options, node }) { + const npub = nip19.npubEncode(node.attrs.id); + return `nostr:${npub}`; + }, + }), ], content: JSON.parse(localStorage.getItem('editor-post') || '{}'), editorProps: { diff --git a/src/libs/storage/instance.ts b/src/libs/storage/instance.ts index 27013a67..60a32a36 100644 --- a/src/libs/storage/instance.ts +++ b/src/libs/storage/instance.ts @@ -12,6 +12,7 @@ import type { NDKCacheEvent, NDKCacheEventTag, NDKCacheUser, + NDKCacheUserProfile, Relays, Widget, } from '@utils/types'; @@ -52,6 +53,20 @@ export class LumeStorage { return await invoke('secure_remove', { key }); } + public async getAllCacheUsers() { + const results: Array = await this.db.select( + 'SELECT * FROM ndk_users ORDER BY createdAt DESC;' + ); + + if (!results.length) return []; + + const users: NDKCacheUserProfile[] = results.map((item) => ({ + pubkey: item.pubkey, + ...JSON.parse(item.profile as string), + })); + return users; + } + public async getCacheUser(pubkey: string) { const results: Array = await this.db.select( 'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;', diff --git a/src/utils/hooks/useSuggestion.ts b/src/utils/hooks/useSuggestion.ts new file mode 100644 index 00000000..ca827bed --- /dev/null +++ b/src/utils/hooks/useSuggestion.ts @@ -0,0 +1,76 @@ +import { MentionOptions } from '@tiptap/extension-mention'; +import { ReactRenderer } from '@tiptap/react'; +import tippy from 'tippy.js'; + +import { MentionList } from '@app/new/components'; + +import { useStorage } from '@libs/storage/provider'; + +export function useSuggestion() { + const { db } = useStorage(); + + const suggestion: MentionOptions['suggestion'] = { + items: async ({ query }) => { + const users = await db.getAllCacheUsers(); + return users + .filter((item) => item.name.toLowerCase().startsWith(query.toLowerCase())) + .slice(0, 5); + }, + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide(); + + return true; + } + + return component.ref?.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }; + + return { suggestion }; +} diff --git a/src/utils/types.d.ts b/src/utils/types.d.ts index 7a0bf0ba..59b8dba9 100644 --- a/src/utils/types.d.ts +++ b/src/utils/types.d.ts @@ -122,6 +122,10 @@ export interface NDKCacheUser { createdAt: number; } +export interface NDKCacheUserProfile extends NDKUserProfile { + pubkey: string; +} + export interface NDKCacheEvent { id: string; pubkey: string;