mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 05:54:19 +01:00
feat: pubkey completion
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user