mirror of
https://github.com/luminous-devs/lume.git
synced 2024-10-02 18:00:47 +00:00
add @ suggestion popup
This commit is contained in:
parent
dc229f40cb
commit
31a53b9c48
@ -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';
|
||||||
|
104
src/app/new/components/mentionList.tsx
Normal file
104
src/app/new/components/mentionList.tsx
Normal 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);
|
@ -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: {
|
||||||
|
@ -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;',
|
||||||
|
76
src/utils/hooks/useSuggestion.ts
Normal file
76
src/utils/hooks/useSuggestion.ts
Normal 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 };
|
||||||
|
}
|
4
src/utils/types.d.ts
vendored
4
src/utils/types.d.ts
vendored
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user