new mention popup in composer
This commit is contained in:
@@ -16,7 +16,7 @@ tauri-build = { version = "1.4.0", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tauri = { version = "1.4.0", features = [
|
tauri = { version = "1.4.0", features = [ "fs-read-dir", "fs-read-file",
|
||||||
"window-start-dragging",
|
"window-start-dragging",
|
||||||
"path-all",
|
"path-all",
|
||||||
"http-all",
|
"http-all",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
"all": false,
|
"all": false,
|
||||||
"removeFile": true,
|
"removeFile": true,
|
||||||
"writeFile": true,
|
"writeFile": true,
|
||||||
|
"readDir": true,
|
||||||
|
"readFile": true,
|
||||||
"scope": [
|
"scope": [
|
||||||
"$APPDATA/*",
|
"$APPDATA/*",
|
||||||
"$DATA/*",
|
"$DATA/*",
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { message } from '@tauri-apps/api/dialog';
|
import { message } from '@tauri-apps/api/dialog';
|
||||||
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 { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
import { Suggestion } from '@shared/composer';
|
import { MentionPopup } from '@shared/composer';
|
||||||
import { CancelIcon, LoaderIcon, MediaIcon, MentionIcon } from '@shared/icons';
|
import { CancelIcon, LoaderIcon, MediaIcon } from '@shared/icons';
|
||||||
import { MentionNote } from '@shared/notes';
|
import { MentionNote } from '@shared/notes';
|
||||||
|
|
||||||
import { useComposer } from '@stores/composer';
|
import { useComposer } from '@stores/composer';
|
||||||
@@ -33,12 +31,6 @@ export function Composer() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({ placeholder: 'Type something...' }),
|
Placeholder.configure({ placeholder: 'Type something...' }),
|
||||||
Mention.configure({
|
|
||||||
suggestion: Suggestion,
|
|
||||||
renderLabel({ node }) {
|
|
||||||
return `nostr:${nip19.npubEncode(node.attrs.id.pubkey)} `;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Image.configure({
|
Image.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class:
|
class:
|
||||||
@@ -164,13 +156,7 @@ export function Composer() {
|
|||||||
<MediaIcon className="h-5 w-5 text-white/80" />
|
<MediaIcon className="h-5 w-5 text-white/80" />
|
||||||
Add media
|
Add media
|
||||||
</button>
|
</button>
|
||||||
<button
|
<MentionPopup editor={editor} />
|
||||||
type="button"
|
|
||||||
onClick={() => uploadImage()}
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg hover:bg-white/10 hover:backdrop-blur-xl"
|
|
||||||
>
|
|
||||||
<MentionIcon className="h-5 w-5 text-white/80" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './modal';
|
export * from './modal';
|
||||||
export * from './composer';
|
export * from './composer';
|
||||||
export * from './mention/list';
|
|
||||||
export * from './mention/item';
|
export * from './mention/item';
|
||||||
export * from './mention/suggestion';
|
export * from './mention/popup';
|
||||||
@@ -1,22 +1,36 @@
|
|||||||
import { Image } from '@shared/image';
|
import { Image } from '@shared/image';
|
||||||
|
|
||||||
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { displayNpub } from '@utils/shortenKey';
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
import { Profile } from '@utils/types';
|
|
||||||
|
|
||||||
export function MentionItem({ profile }: { profile: Profile }) {
|
export function MentionItem({ pubkey }: { pubkey: string }) {
|
||||||
|
const { status, user } = useProfile(pubkey);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||||
|
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||||
|
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||||
|
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-white/10">
|
||||||
<Image
|
<Image
|
||||||
src={profile.picture || profile.image}
|
src={user.picture || user.image}
|
||||||
alt={profile.pubkey}
|
alt={pubkey}
|
||||||
className="shirnk-0 h-8 w-8 rounded-md object-cover"
|
className="shirnk-0 h-8 w-8 rounded-md object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-px">
|
<div className="flex flex-col items-start gap-px">
|
||||||
<h5 className="max-w-[15rem] text-sm font-medium leading-none text-white">
|
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-white">
|
||||||
{profile.ident}
|
{user.display_name || user.displayName || user.name}
|
||||||
</h5>
|
</h5>
|
||||||
<span className="text-sm leading-none text-white/50">
|
<span className="text-sm leading-none text-white/50">
|
||||||
{displayNpub(profile.pubkey, 16)}
|
{displayNpub(pubkey, 16)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
|
|
||||||
import { type SuggestionProps } from '@tiptap/suggestion';
|
|
||||||
import {
|
|
||||||
ForwardedRef,
|
|
||||||
forwardRef,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
import { MentionItem } from '@shared/composer';
|
|
||||||
|
|
||||||
export const MentionList = forwardRef(
|
|
||||||
(props: SuggestionProps, ref: ForwardedRef<unknown>) => {
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
||||||
|
|
||||||
const selectItem = (index) => {
|
|
||||||
const item = props.items[index];
|
|
||||||
|
|
||||||
if (item) {
|
|
||||||
props.command({ id: item });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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-[250px] flex-col rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
|
||||||
{props.items.length ? (
|
|
||||||
props.items.map((item: NDKUserProfile, index: number) => (
|
|
||||||
<button
|
|
||||||
className={twMerge(
|
|
||||||
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-white/10',
|
|
||||||
`${index === selectedIndex ? 'is-selected' : ''}`
|
|
||||||
)}
|
|
||||||
key={index}
|
|
||||||
onClick={() => selectItem(index)}
|
|
||||||
>
|
|
||||||
<MentionItem profile={item} />
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div>No result</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
MentionList.displayName = 'MentionList';
|
|
||||||
@@ -1,7 +1,38 @@
|
|||||||
export function MentionPopup() {
|
import * as Popover from '@radix-ui/react-popover';
|
||||||
|
import { Editor } from '@tiptap/react';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { MentionItem } from '@shared/composer';
|
||||||
|
import { MentionIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
export function MentionPopup({ editor }: { editor: Editor }) {
|
||||||
|
const { db } = useStorage();
|
||||||
|
|
||||||
|
const insertMention = (pubkey: string) => {
|
||||||
|
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Popover.Root>
|
||||||
<p>TODO</p>
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-lg hover:bg-white/10 hover:backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
<MentionIcon className="h-5 w-5 text-white/80" />
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg bg-white/10 backdrop-blur-xl focus:outline-none">
|
||||||
|
<div className="flex flex-col gap-1 py-1">
|
||||||
|
{db.account.follows.map((item) => (
|
||||||
|
<button key={item} type="button" onClick={() => insertMention(item)}>
|
||||||
|
<MentionItem pubkey={item} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { ReactRenderer } from '@tiptap/react';
|
|
||||||
import tippy from 'tippy.js';
|
|
||||||
|
|
||||||
import { MentionList } from '@shared/composer';
|
|
||||||
|
|
||||||
export const Suggestion = {
|
|
||||||
items: async ({ query }) => {
|
|
||||||
const users = [];
|
|
||||||
return users
|
|
||||||
.filter((item) => item.ident.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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -50,7 +50,7 @@ export function NoteActions({
|
|||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
<Tooltip.Content className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
<Tooltip.Content className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||||
Open thread
|
Focus
|
||||||
<Tooltip.Arrow className="fill-black" />
|
<Tooltip.Arrow className="fill-black" />
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Portal>
|
</Tooltip.Portal>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function User({
|
|||||||
</div>
|
</div>
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
className="w-[300px] overflow-hidden rounded-md bg-white/10 backdrop-blur-3xl focus:outline-none"
|
className="w-[300px] overflow-hidden rounded-lg bg-white/10 backdrop-blur-3xl focus:outline-none"
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2.5 border-b border-white/5 px-3 py-3">
|
<div className="flex gap-2.5 border-b border-white/5 px-3 py-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user