feat: pubkey completion

This commit is contained in:
Shusui MOYATANI
2024-02-23 19:01:51 +09:00
parent e1f4be0a9b
commit 9ad7e34ae3
3 changed files with 199 additions and 88 deletions

View File

@@ -1,24 +1,38 @@
import { createSignal, createMemo, Show, For, type Component, type JSX } from 'solid-js';
import {
createSignal,
createMemo,
Show,
For,
type Component,
type JSX,
createEffect,
onCleanup,
} from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import { Textcomplete } from '@textcomplete/core';
import { TextareaEditor } from '@textcomplete/textarea';
import ExclamationTriangle from 'heroicons/24/outline/exclamation-triangle.svg';
import FaceSmile from 'heroicons/24/outline/face-smile.svg';
import Photo from 'heroicons/24/outline/photo.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import uniq from 'lodash/uniq';
import * as nip19 from 'nostr-tools/nip19';
import { Event as NostrEvent } from 'nostr-tools/pure';
import useEmojiPicker, { EmojiData } from '@/components/useEmojiPicker';
import UserNameDisplay from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig';
import useEmojiComplete from '@/hooks/useEmojiComplete';
import useConfig, { CustomEmojiConfig } from '@/core/useConfig';
import { useTranslation } from '@/i18n/useTranslation';
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
import { textNote } from '@/nostr/event';
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
import useCommands from '@/nostr/useCommands';
import useFollowings from '@/nostr/useFollowings';
import { UseProfile, useProfiles } from '@/nostr/useProfile';
import usePubkey from '@/nostr/usePubkey';
import ensureNonNull from '@/utils/ensureNonNull';
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
// import usePersistStatus from '@/hooks/usePersistStatus';
@@ -78,6 +92,87 @@ const format = (parsed: ParsedTextNote) => {
return content.join('');
};
const useComplete = () => {
const { searchEmojis } = useConfig();
const pubkey = usePubkey();
const { followingPubkeys } = useFollowings(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({ pubkey: pubkeyNonNull })),
);
const { searchProfiles } = useProfiles(() => ({
pubkeys: followingPubkeys(),
}));
const [elementRef, setElementRef] = createSignal<HTMLTextAreaElement | undefined>();
createEffect(() => {
const el = elementRef();
if (el == null) return;
const editor = new TextareaEditor(el);
const textcomplete = new Textcomplete(
editor,
[
{
id: 'customEmoji',
match: /\B:(\w+)$/,
search: (term, callback) => {
callback(searchEmojis(term));
},
template: (config: CustomEmojiConfig) => {
const e = (
<div class="flex gap-1 border-b border-border px-2 py-1">
<img class="h-6 max-w-[3rem]" src={config.url} alt={config.shortcode} />
<div>{config.shortcode}</div>
</div>
) as HTMLElement;
return e.outerHTML;
},
replace: (result: CustomEmojiConfig) => `:${result.shortcode}: `,
},
{
id: 'profiles',
match: /\B@(.+)$/,
search: (term, callback) => {
callback(searchProfiles(term));
},
template: (profile: UseProfile) => {
const e = (
<div class="flex gap-1 border-b border-border px-2 py-1">
<Show when={profile.profile()?.picture} fallback={<div class="h-6" />} keyed>
{(url) => <img class="h-6 max-w-[3rem]" src={url} alt="icon" />}
</Show>
{profile.profile()?.name}
</div>
) as HTMLElement;
return e.outerHTML;
},
replace: (result: UseProfile) => {
const selectedPubkey = result.pubkey();
if (selectedPubkey == null) return '';
return nip19.npubEncode(selectedPubkey);
},
},
],
{
dropdown: {
className: 'bg-bg shadow rounded',
item: {
className: 'cursor-pointer',
activeClassName: 'bg-bg-tertiary cursor-pointer',
},
},
},
);
onCleanup(() => {
textcomplete.destroy();
});
});
return { elementRef: setElementRef };
};
const NotePostForm: Component<NotePostFormProps> = (props) => {
const i18n = useTranslation();
@@ -85,7 +180,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
let contentWarningReasonRef: HTMLInputElement | undefined;
let fileInputRef: HTMLInputElement | undefined;
const { elementRef: emojiTextAreaRef } = useEmojiComplete();
const { elementRef: completeTextAreaRef } = useComplete();
const [text, setText] = createSignal<string>('');
const [contentWarning, setContentWarning] = createSignal(false);
const [contentWarningReason, setContentWarningReason] = createSignal('');
@@ -381,7 +476,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
ref={(el) => {
textAreaRef = el;
props.textAreaRef?.(el);
emojiTextAreaRef(el);
completeTextAreaRef(el);
}}
name="text"
class="scrollbar max-h-[40vh] min-h-[4rem] overflow-y-auto rounded-md border border-border bg-bg ring-border placeholder:text-fg-secondary focus:border-border focus:ring-primary"

View File

@@ -1,58 +0,0 @@
import { createEffect, createSignal, onCleanup } from 'solid-js';
import { Textcomplete } from '@textcomplete/core';
import { TextareaEditor } from '@textcomplete/textarea';
import useConfig, { CustomEmojiConfig } from '@/core/useConfig';
const useEmojiComplete = () => {
const { searchEmojis } = useConfig();
const [elementRef, setElementRef] = createSignal<HTMLTextAreaElement | undefined>();
createEffect(() => {
const el = elementRef();
if (el == null) return;
const editor = new TextareaEditor(el);
const textcomplete = new Textcomplete(
editor,
[
{
id: 'customEmoji',
match: /\B:(\w+)$/,
search: (term, callback) => {
callback(searchEmojis(term));
},
template: (config: CustomEmojiConfig) => {
const e = (
<div class="flex gap-1 border-b border-border px-2 py-1">
<img class="h-6 max-w-[3rem]" src={config.url} alt={config.shortcode} />
<div>{config.shortcode}</div>
</div>
) as HTMLElement;
return e.outerHTML;
},
replace: (result: CustomEmojiConfig) => `:${result.shortcode}: `,
},
],
{
dropdown: {
className: 'bg-bg shadow rounded',
item: {
className: 'cursor-pointer',
activeClassName: 'bg-bg-tertiary cursor-pointer',
},
},
},
);
onCleanup(() => {
textcomplete.destroy();
});
});
return { elementRef: setElementRef };
};
export default useEmojiComplete;

View File

@@ -1,6 +1,12 @@
import { createMemo } from 'solid-js';
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import {
createQuery,
useQueryClient,
type CreateQueryResult,
QueryClient,
createQueries,
} from '@tanstack/solid-query';
import { Event as NostrEvent } from 'nostr-tools/pure';
import { Profile, ProfileWithOtherProperties, safeParseProfile } from '@/nostr/event/Profile';
@@ -13,6 +19,7 @@ export type UseProfileProps = {
export type UseProfile = {
profile: () => ProfileWithOtherProperties | null;
pubkey: () => string | undefined;
event: () => NostrEvent | null | undefined;
lud06: () => string | undefined;
lud16: () => string | undefined;
@@ -30,29 +37,17 @@ export type UseProfiles = {
queries: CreateQueryResult<NostrEvent | null>[];
};
const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const genQueryKey = createMemo(() => ['useProfile', props()] as const);
const genQueryKey = (props: UseProfileProps | null) => ['useProfile', props] as const;
const query = createQuery(() => ({
queryKey: genQueryKey(),
queryFn: latestEventQuery<ReturnType<typeof genQueryKey>>({
taskProvider: ([, currentProps]) => {
if (currentProps == null) return null;
const { pubkey } = currentProps;
return new BatchedEventsTask<ProfileTask>({ type: 'Profile', pubkey });
},
const buildMethod = ({
props,
query,
queryClient,
}),
// Profiles are updated occasionally, so a short staleTime is used here.
// gcTime is long so that the user see profiles instantly.
staleTime: 5 * 60 * 1000, // 5 min
gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days
refetchInterval: 5 * 60 * 1000, // 5 min
refetchOnWindowFocus: false,
}));
}: {
props: () => UseProfileProps | null;
query: CreateQueryResult<NostrEvent | null>;
queryClient: QueryClient;
}): UseProfile => {
const event = () => query.data;
const profile = createMemo((): Profile | null => {
@@ -76,9 +71,88 @@ const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile =>
const isZapConfigured = (): boolean => lud06() != null || lud16() != null;
const invalidateProfile = (): Promise<void> =>
queryClient.invalidateQueries({ queryKey: genQueryKey() });
queryClient.invalidateQueries({ queryKey: genQueryKey(props()) });
return { profile, lud06, lud16, event, isZapConfigured, invalidateProfile, query };
return {
profile,
pubkey: () => props()?.pubkey,
lud06,
lud16,
event,
isZapConfigured,
invalidateProfile,
query,
};
};
const queryOptions = ({
props,
queryClient,
}: {
props: () => UseProfileProps | null;
queryClient: QueryClient;
}) => ({
queryKey: genQueryKey(props()),
queryFn: latestEventQuery<ReturnType<typeof genQueryKey>>({
taskProvider: ([, currentProps]) => {
if (currentProps == null) return null;
const { pubkey } = currentProps;
return new BatchedEventsTask<ProfileTask>({ type: 'Profile', pubkey });
},
queryClient,
}),
// Profiles are updated occasionally, so a short staleTime is used here.
// gcTime is long so that the user see profiles instantly.
staleTime: 5 * 60 * 1000, // 5 min
gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days
refetchInterval: 5 * 60 * 1000, // 5 min
refetchOnWindowFocus: false,
});
const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const query = createQuery(() => queryOptions({ props, queryClient }));
return buildMethod({ props, query, queryClient });
};
export const useProfiles = (propsProvider: () => UseProfilesProps) => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const queries = createQueries(() => ({
queries: props().pubkeys.map((pubkey) =>
queryOptions({
props: () => ({ pubkey }),
queryClient,
}),
),
}));
const profiles = createMemo((): UseProfile[] =>
queries.map((query, i) =>
buildMethod({
props: () => ({ pubkey: props().pubkeys[i] }),
query,
queryClient,
}),
),
);
const searchProfiles = (query: string) =>
profiles().filter(
(profile) =>
profile.profile()?.name?.includes(query) ||
profile.profile()?.display_name?.includes(query) ||
profile.profile()?.nip05?.includes(query),
);
return {
profiles,
searchProfiles,
queries,
};
};
export default useProfile;