mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 05:54:19 +01:00
@@ -1,38 +1,24 @@
|
|||||||
import {
|
import { createSignal, createMemo, Show, For, type Component, type JSX } from 'solid-js';
|
||||||
createSignal,
|
|
||||||
createMemo,
|
|
||||||
Show,
|
|
||||||
For,
|
|
||||||
type Component,
|
|
||||||
type JSX,
|
|
||||||
createEffect,
|
|
||||||
onCleanup,
|
|
||||||
} from 'solid-js';
|
|
||||||
|
|
||||||
import { createMutation } from '@tanstack/solid-query';
|
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 ExclamationTriangle from 'heroicons/24/outline/exclamation-triangle.svg';
|
||||||
import FaceSmile from 'heroicons/24/outline/face-smile.svg';
|
import FaceSmile from 'heroicons/24/outline/face-smile.svg';
|
||||||
import Photo from 'heroicons/24/outline/photo.svg';
|
import Photo from 'heroicons/24/outline/photo.svg';
|
||||||
import XMark from 'heroicons/24/outline/x-mark.svg';
|
import XMark from 'heroicons/24/outline/x-mark.svg';
|
||||||
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
|
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
|
||||||
import uniq from 'lodash/uniq';
|
import uniq from 'lodash/uniq';
|
||||||
import * as nip19 from 'nostr-tools/nip19';
|
|
||||||
import { Event as NostrEvent } from 'nostr-tools/pure';
|
import { Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
import useEmojiPicker, { EmojiData } from '@/components/useEmojiPicker';
|
import useEmojiPicker, { EmojiData } from '@/components/useEmojiPicker';
|
||||||
import UserNameDisplay from '@/components/UserDisplayName';
|
import UserNameDisplay from '@/components/UserDisplayName';
|
||||||
import useConfig, { CustomEmojiConfig } from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
|
import useEmojiComplete from '@/hooks/useEmojiComplete';
|
||||||
import { useTranslation } from '@/i18n/useTranslation';
|
import { useTranslation } from '@/i18n/useTranslation';
|
||||||
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
|
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
|
||||||
import { textNote } from '@/nostr/event';
|
import { textNote } from '@/nostr/event';
|
||||||
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
|
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
|
||||||
import useCommands from '@/nostr/useCommands';
|
import useCommands from '@/nostr/useCommands';
|
||||||
import useFollowings from '@/nostr/useFollowings';
|
|
||||||
import { UseProfile, useProfiles } from '@/nostr/useProfile';
|
|
||||||
import usePubkey from '@/nostr/usePubkey';
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
import ensureNonNull from '@/utils/ensureNonNull';
|
|
||||||
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
|
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
|
||||||
// import usePersistStatus from '@/hooks/usePersistStatus';
|
// import usePersistStatus from '@/hooks/usePersistStatus';
|
||||||
|
|
||||||
@@ -92,87 +78,6 @@ const format = (parsed: ParsedTextNote) => {
|
|||||||
return content.join('');
|
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 NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||||
const i18n = useTranslation();
|
const i18n = useTranslation();
|
||||||
|
|
||||||
@@ -180,7 +85,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
let contentWarningReasonRef: HTMLInputElement | undefined;
|
let contentWarningReasonRef: HTMLInputElement | undefined;
|
||||||
let fileInputRef: HTMLInputElement | undefined;
|
let fileInputRef: HTMLInputElement | undefined;
|
||||||
|
|
||||||
const { elementRef: completeTextAreaRef } = useComplete();
|
const { elementRef: emojiTextAreaRef } = useEmojiComplete();
|
||||||
const [text, setText] = createSignal<string>('');
|
const [text, setText] = createSignal<string>('');
|
||||||
const [contentWarning, setContentWarning] = createSignal(false);
|
const [contentWarning, setContentWarning] = createSignal(false);
|
||||||
const [contentWarningReason, setContentWarningReason] = createSignal('');
|
const [contentWarningReason, setContentWarningReason] = createSignal('');
|
||||||
@@ -476,7 +381,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
textAreaRef = el;
|
textAreaRef = el;
|
||||||
props.textAreaRef?.(el);
|
props.textAreaRef?.(el);
|
||||||
completeTextAreaRef(el);
|
emojiTextAreaRef(el);
|
||||||
}}
|
}}
|
||||||
name="text"
|
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"
|
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"
|
||||||
|
|||||||
58
src/hooks/useEmojiComplete.tsx
Normal file
58
src/hooks/useEmojiComplete.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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,12 +1,6 @@
|
|||||||
import { createMemo } from 'solid-js';
|
import { createMemo } from 'solid-js';
|
||||||
|
|
||||||
import {
|
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
|
||||||
createQuery,
|
|
||||||
useQueryClient,
|
|
||||||
type CreateQueryResult,
|
|
||||||
QueryClient,
|
|
||||||
createQueries,
|
|
||||||
} from '@tanstack/solid-query';
|
|
||||||
import { Event as NostrEvent } from 'nostr-tools/pure';
|
import { Event as NostrEvent } from 'nostr-tools/pure';
|
||||||
|
|
||||||
import { Profile, ProfileWithOtherProperties, safeParseProfile } from '@/nostr/event/Profile';
|
import { Profile, ProfileWithOtherProperties, safeParseProfile } from '@/nostr/event/Profile';
|
||||||
@@ -19,7 +13,6 @@ export type UseProfileProps = {
|
|||||||
|
|
||||||
export type UseProfile = {
|
export type UseProfile = {
|
||||||
profile: () => ProfileWithOtherProperties | null;
|
profile: () => ProfileWithOtherProperties | null;
|
||||||
pubkey: () => string | undefined;
|
|
||||||
event: () => NostrEvent | null | undefined;
|
event: () => NostrEvent | null | undefined;
|
||||||
lud06: () => string | undefined;
|
lud06: () => string | undefined;
|
||||||
lud16: () => string | undefined;
|
lud16: () => string | undefined;
|
||||||
@@ -37,17 +30,29 @@ export type UseProfiles = {
|
|||||||
queries: CreateQueryResult<NostrEvent | null>[];
|
queries: CreateQueryResult<NostrEvent | null>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const genQueryKey = (props: UseProfileProps | null) => ['useProfile', props] as const;
|
const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const props = createMemo(propsProvider);
|
||||||
|
const genQueryKey = createMemo(() => ['useProfile', props()] as const);
|
||||||
|
|
||||||
const buildMethod = ({
|
const query = createQuery(() => ({
|
||||||
props,
|
queryKey: genQueryKey(),
|
||||||
query,
|
queryFn: latestEventQuery<ReturnType<typeof genQueryKey>>({
|
||||||
|
taskProvider: ([, currentProps]) => {
|
||||||
|
if (currentProps == null) return null;
|
||||||
|
const { pubkey } = currentProps;
|
||||||
|
return new BatchedEventsTask<ProfileTask>({ type: 'Profile', pubkey });
|
||||||
|
},
|
||||||
queryClient,
|
queryClient,
|
||||||
}: {
|
}),
|
||||||
props: () => UseProfileProps | null;
|
// Profiles are updated occasionally, so a short staleTime is used here.
|
||||||
query: CreateQueryResult<NostrEvent | null>;
|
// gcTime is long so that the user see profiles instantly.
|
||||||
queryClient: QueryClient;
|
staleTime: 5 * 60 * 1000, // 5 min
|
||||||
}): UseProfile => {
|
gcTime: 3 * 24 * 60 * 60 * 1000, // 3 days
|
||||||
|
refetchInterval: 5 * 60 * 1000, // 5 min
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}));
|
||||||
|
|
||||||
const event = () => query.data;
|
const event = () => query.data;
|
||||||
|
|
||||||
const profile = createMemo((): Profile | null => {
|
const profile = createMemo((): Profile | null => {
|
||||||
@@ -71,88 +76,9 @@ const buildMethod = ({
|
|||||||
const isZapConfigured = (): boolean => lud06() != null || lud16() != null;
|
const isZapConfigured = (): boolean => lud06() != null || lud16() != null;
|
||||||
|
|
||||||
const invalidateProfile = (): Promise<void> =>
|
const invalidateProfile = (): Promise<void> =>
|
||||||
queryClient.invalidateQueries({ queryKey: genQueryKey(props()) });
|
queryClient.invalidateQueries({ queryKey: genQueryKey() });
|
||||||
|
|
||||||
return {
|
return { profile, lud06, lud16, event, isZapConfigured, invalidateProfile, query };
|
||||||
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;
|
export default useProfile;
|
||||||
|
|||||||
Reference in New Issue
Block a user