diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 4b8e752..a736411 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -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(); + + 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 = ( +
+ {config.shortcode} +
{config.shortcode}
+
+ ) 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 = ( +
+ } keyed> + {(url) => icon} + + {profile.profile()?.name} +
+ ) 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 = (props) => { const i18n = useTranslation(); @@ -85,7 +180,7 @@ const NotePostForm: Component = (props) => { let contentWarningReasonRef: HTMLInputElement | undefined; let fileInputRef: HTMLInputElement | undefined; - const { elementRef: emojiTextAreaRef } = useEmojiComplete(); + const { elementRef: completeTextAreaRef } = useComplete(); const [text, setText] = createSignal(''); const [contentWarning, setContentWarning] = createSignal(false); const [contentWarningReason, setContentWarningReason] = createSignal(''); @@ -381,7 +476,7 @@ const NotePostForm: Component = (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" diff --git a/src/hooks/useEmojiComplete.tsx b/src/hooks/useEmojiComplete.tsx deleted file mode 100644 index b5090f1..0000000 --- a/src/hooks/useEmojiComplete.tsx +++ /dev/null @@ -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(); - - 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 = ( -
- {config.shortcode} -
{config.shortcode}
-
- ) 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; diff --git a/src/nostr/useProfile.ts b/src/nostr/useProfile.ts index 3a9ac6c..d1e460a 100644 --- a/src/nostr/useProfile.ts +++ b/src/nostr/useProfile.ts @@ -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[]; }; -const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => { - const queryClient = useQueryClient(); - const props = createMemo(propsProvider); - const genQueryKey = createMemo(() => ['useProfile', props()] as const); - - const query = createQuery(() => ({ - queryKey: genQueryKey(), - queryFn: latestEventQuery>({ - taskProvider: ([, currentProps]) => { - if (currentProps == null) return null; - const { pubkey } = currentProps; - return new BatchedEventsTask({ 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 genQueryKey = (props: UseProfileProps | null) => ['useProfile', props] as const; +const buildMethod = ({ + props, + query, + queryClient, +}: { + props: () => UseProfileProps | null; + query: CreateQueryResult; + 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 => - 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>({ + taskProvider: ([, currentProps]) => { + if (currentProps == null) return null; + const { pubkey } = currentProps; + return new BatchedEventsTask({ 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;