add @ suggestion popup

This commit is contained in:
reya 2023-11-25 15:41:18 +07:00
parent dc229f40cb
commit 31a53b9c48
6 changed files with 212 additions and 0 deletions

View File

@ -2,3 +2,4 @@ export * from './articleCoverUploader';
export * from './mediaUploader'; export * from './mediaUploader';
export * from './mentionPopup'; export * from './mentionPopup';
export * from './mentionPopupItem'; export * from './mentionPopupItem';
export * from './mentionList';

View File

@ -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<unknown>
) => {
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 (
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
{props.items.length ? (
props.items.map((item, index) => (
<button
key={index}
onClick={() => selectItem(index)}
className={twMerge(
'inline-flex h-11 items-center gap-2 rounded-md px-2',
index === selectedIndex ? 'bg-neutral-100 dark:bg-neutral-900' : ''
)}
>
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={150}>
<img
src={
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(item.name, 90, 50))
}
alt={item.name}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[150px] truncate text-sm font-medium">{item.name}</h5>
</button>
))
) : (
<div className="text-center text-sm font-medium">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);

View File

@ -1,10 +1,12 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import CharacterCount from '@tiptap/extension-character-count'; import CharacterCount from '@tiptap/extension-character-count';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { nip19 } from 'nostr-tools';
import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -18,11 +20,13 @@ import { MentionNote } from '@shared/notes';
import { WIDGET_KIND } from '@stores/constants'; import { WIDGET_KIND } from '@stores/constants';
import { useSuggestion } from '@utils/hooks/useSuggestion';
import { useWidget } from '@utils/hooks/useWidget'; import { useWidget } from '@utils/hooks/useWidget';
export function NewPostScreen() { export function NewPostScreen() {
const { ndk } = useNDK(); const { ndk } = useNDK();
const { addWidget } = useWidget(); const { addWidget } = useWidget();
const { suggestion } = useSuggestion();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [height, setHeight] = useState(0); const [height, setHeight] = useState(0);
@ -41,6 +45,14 @@ export function NewPostScreen() {
}, },
}), }),
CharacterCount.configure(), 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') || '{}'), content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
editorProps: { editorProps: {

View File

@ -12,6 +12,7 @@ import type {
NDKCacheEvent, NDKCacheEvent,
NDKCacheEventTag, NDKCacheEventTag,
NDKCacheUser, NDKCacheUser,
NDKCacheUserProfile,
Relays, Relays,
Widget, Widget,
} from '@utils/types'; } from '@utils/types';
@ -52,6 +53,20 @@ export class LumeStorage {
return await invoke('secure_remove', { key }); return await invoke('secure_remove', { key });
} }
public async getAllCacheUsers() {
const results: Array<NDKCacheUser> = 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) { public async getCacheUser(pubkey: string) {
const results: Array<NDKCacheUser> = await this.db.select( const results: Array<NDKCacheUser> = await this.db.select(
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;', 'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',

View File

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

View File

@ -122,6 +122,10 @@ export interface NDKCacheUser {
createdAt: number; createdAt: number;
} }
export interface NDKCacheUserProfile extends NDKUserProfile {
pubkey: string;
}
export interface NDKCacheEvent { export interface NDKCacheEvent {
id: string; id: string;
pubkey: string; pubkey: string;