mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-13 06:09:42 +02:00
Add insert gif button
This commit is contained in:
parent
9c9a1b588c
commit
5403d37a49
5
.changeset/strong-dolphins-happen.md
Normal file
5
.changeset/strong-dolphins-happen.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add insert gif button
|
136
src/components/gif/gif-picker-modal.tsx
Normal file
136
src/components/gif/gif-picker-modal.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
FlexProps,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
ModalProps,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import { kinds, NostrEvent } from "nostr-tools";
|
||||
import { getEventUID, getTagValue } from "applesauce-core/helpers";
|
||||
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
import SearchRelayPicker, { useSearchRelay } from "../../views/search/components/search-relay-picker";
|
||||
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
|
||||
import { ListId, usePeopleListSelect } from "../../providers/local/people-list-provider";
|
||||
|
||||
function GifCard({ gif, onClick }: Omit<FlexProps, "children" | "onClick"> & { gif: NostrEvent; onClick: () => void }) {
|
||||
const url = getTagValue(gif, "url");
|
||||
const thumb = getTagValue(gif, "thumb");
|
||||
|
||||
const ref = useEventIntersectionRef(gif);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<Flex ref={ref} alignItems="center" justifyContent="center" position="relative">
|
||||
{/* <Flex direction="column" h="full">
|
||||
<UserAvatarLink pubkey={gif.pubkey} size="sm" />
|
||||
<SingleZapButton event={gif} showEventPreview={false} variant="ghost" size="sm" />
|
||||
<DebugEventButton event={gif} position="absolute" variant="ghost" size="sm" mt="auto" />
|
||||
</Flex> */}
|
||||
<button onClick={onClick}>
|
||||
<Image src={thumb || url} rounded="md" minH="20" minW="20" />
|
||||
</button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
type GifPickerProps = Omit<ModalProps, "children"> & { onSelect: (gif: NostrEvent) => void };
|
||||
|
||||
export default function GifPickerModal({ onClose, isOpen, onSelect, ...props }: GifPickerProps) {
|
||||
const [search, setSearch] = useState<string>();
|
||||
const [searchRelayUrl, setSearchRelayUrl] = useState<string>();
|
||||
|
||||
const [list, setList] = useState<ListId>("global");
|
||||
const { selected, setSelected, filter, listId } = usePeopleListSelect(list, setList);
|
||||
|
||||
const searchRelay = useSearchRelay(searchRelayUrl);
|
||||
|
||||
const [debounceSearch, setDebounceSearch] = useState<string>();
|
||||
useEffect(() => {
|
||||
setDebounceSearch(undefined);
|
||||
const t = setTimeout(() => setDebounceSearch(search), 600);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [search, setDebounceSearch]);
|
||||
|
||||
const baseFilter = {
|
||||
kinds: [kinds.FileMetadata],
|
||||
"#m": ["image/gif"],
|
||||
...filter,
|
||||
};
|
||||
|
||||
const readRelays = useReadRelays();
|
||||
const { loader, timeline } = useTimelineLoader(
|
||||
[listId, "gifs", searchRelay?.url ?? "all", debounceSearch ?? "all"].join("-"),
|
||||
searchRelay !== undefined ? [searchRelay] : readRelays,
|
||||
debounceSearch !== undefined ? { ...baseFilter, search: debounceSearch } : baseFilter,
|
||||
);
|
||||
|
||||
const callback = useTimelineCurserIntersectionCallback(loader);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size={{ base: "full", md: "6xl" }} {...props}>
|
||||
<ModalOverlay />
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<ModalContent>
|
||||
<ModalHeader p="2" pr="16">
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Input
|
||||
type="search"
|
||||
maxW="sm"
|
||||
placeholder="Search gifs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<SearchRelayPicker value={searchRelayUrl} onChange={(e) => setSearchRelayUrl(e.target.value)} />
|
||||
<Button type="submit">Search</Button>
|
||||
</Flex>
|
||||
<ButtonGroup size="xs">
|
||||
<Button colorScheme={selected === "global" ? "primary" : undefined} onClick={() => setSelected("global")}>
|
||||
Global
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={selected === "following" ? "primary" : undefined}
|
||||
onClick={() => setSelected("following")}
|
||||
>
|
||||
Follows
|
||||
</Button>
|
||||
<Button colorScheme={selected === "self" ? "primary" : undefined} onClick={() => setSelected("self")}>
|
||||
Personal
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody p="4">
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} gap="2">
|
||||
{timeline.map((gif) => (
|
||||
<GifCard
|
||||
key={getEventUID(gif)}
|
||||
gif={gif}
|
||||
onClick={() => {
|
||||
onSelect(gif);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</IntersectionObserverProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
35
src/components/gif/insert-gif-button.tsx
Normal file
35
src/components/gif/insert-gif-button.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
|
||||
import Clapperboard from "../icons/clapperboard";
|
||||
import GifPickerModal from "./gif-picker-modal";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { useCallback } from "react";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
|
||||
export default function InsertGifButton({
|
||||
onSelect,
|
||||
onSelectURL,
|
||||
...props
|
||||
}: Omit<IconButtonProps, "icon" | "onSelect"> & {
|
||||
onSelect?: (gif: NostrEvent) => void;
|
||||
onSelectURL?: (url: string) => void;
|
||||
}) {
|
||||
const modal = useDisclosure();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(gif: NostrEvent) => {
|
||||
if (onSelect) onSelect(gif);
|
||||
if (onSelectURL) {
|
||||
const url = getTagValue(gif, "url");
|
||||
if (url) onSelectURL(url);
|
||||
}
|
||||
},
|
||||
[onSelect, onSelectURL],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton icon={<Clapperboard boxSize={5} />} onClick={modal.onOpen} {...props} />
|
||||
{modal.isOpen && <GifPickerModal onClose={modal.onClose} isOpen onSelect={handleSelect} />}
|
||||
</>
|
||||
);
|
||||
}
|
46
src/components/gif/single-zap-button.tsx
Normal file
46
src/components/gif/single-zap-button.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { ButtonProps, IconButton, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { LightningIcon } from "../icons";
|
||||
import ZapModal from "../event-zap-modal";
|
||||
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
||||
|
||||
export type SingleZapButton = Omit<ButtonProps, "children"> & {
|
||||
event: NostrEvent;
|
||||
allowComment?: boolean;
|
||||
showEventPreview?: boolean;
|
||||
};
|
||||
|
||||
export default function SingleZapButton({ event, allowComment, showEventPreview, ...props }: SingleZapButton) {
|
||||
const { metadata } = useUserLNURLMetadata(event.pubkey);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const canZap = !!metadata?.allowsNostr || event.tags.some((t) => t[0] === "zap");
|
||||
|
||||
if (!canZap) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<LightningIcon color="yellow.400" verticalAlign="sub" />}
|
||||
aria-label="Zap"
|
||||
title="Zap"
|
||||
{...props}
|
||||
onClick={onOpen}
|
||||
isDisabled={!canZap}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<ZapModal
|
||||
isOpen={isOpen}
|
||||
pubkey={event.pubkey}
|
||||
event={event}
|
||||
onClose={onClose}
|
||||
onZapped={onClose}
|
||||
allowComment={allowComment}
|
||||
showEmbed={showEventPreview}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -54,7 +54,7 @@ import ZapSplitCreator, { fillRemainingPercent } from "./zap-split-creator";
|
||||
import { EventSplit } from "../../helpers/nostr/zaps";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import useCacheForm from "../../hooks/use-cache-form";
|
||||
import { useTextAreaUploadFileWithForm } from "../../hooks/use-textarea-upload-file";
|
||||
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../hooks/use-textarea-upload-file";
|
||||
import MinePOW from "../pow/mine-pow";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import { ErrorBoundary } from "../error-boundary";
|
||||
@ -62,6 +62,8 @@ import { useFinalizeDraft, usePublishEvent } from "../../providers/global/publis
|
||||
import { TextNoteContents } from "../note/timeline-note/text-note-contents";
|
||||
import localSettings from "../../services/local-settings";
|
||||
import useLocalStorageDisclosure from "../../hooks/use-localstorage-disclosure";
|
||||
import InsertGifButton from "../gif/insert-gif-button";
|
||||
import InsertImageButton from "./insert-image-button";
|
||||
|
||||
type FormValues = {
|
||||
subject: string;
|
||||
@ -162,7 +164,8 @@ export default function PostModal({
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const { onPaste, onFileInputChange, uploading } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
|
||||
const insertText = useTextAreaInsertTextWithForm(textAreaRef, getValues, setValue);
|
||||
const { onPaste } = useTextAreaUploadFile(insertText);
|
||||
|
||||
const publishPost = async (unsigned?: UnsignedEvent) => {
|
||||
unsigned = unsigned || draft || (await updateDraft());
|
||||
@ -240,19 +243,8 @@ export default function PostModal({
|
||||
)}
|
||||
<Flex gap="2" alignItems="center" justifyContent="flex-end">
|
||||
<Flex mr="auto" gap="2">
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
accept="image/*,audio/*,video/*"
|
||||
ref={imageUploadRef}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<UploadImageIcon boxSize={6} />}
|
||||
aria-label="Upload Image"
|
||||
title="Upload Image"
|
||||
onClick={() => imageUploadRef.current?.click()}
|
||||
isLoading={uploading}
|
||||
/>
|
||||
<InsertImageButton onUploaded={insertText} aria-label="Upload image" />
|
||||
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
|
||||
<Button
|
||||
variant="link"
|
||||
rightIcon={moreOptions.isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
|
30
src/components/post-modal/insert-image-button.tsx
Normal file
30
src/components/post-modal/insert-image-button.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useRef } from "react";
|
||||
import { IconButton, IconButtonProps, VisuallyHiddenInput } from "@chakra-ui/react";
|
||||
|
||||
import { UploadImageIcon } from "../icons";
|
||||
import useTextAreaUploadFile from "../../hooks/use-textarea-upload-file";
|
||||
|
||||
export default function InsertImageButton({
|
||||
onUploaded,
|
||||
...props
|
||||
}: Omit<IconButtonProps, "icon"> & { onUploaded: (url: string) => void }) {
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const { onFileInputChange, uploading } = useTextAreaUploadFile(onUploaded);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
accept="image/*,audio/*,video/*"
|
||||
ref={imageUploadRef}
|
||||
onChange={onFileInputChange}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<UploadImageIcon boxSize={6} />}
|
||||
onClick={() => imageUploadRef.current?.click()}
|
||||
isLoading={uploading}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
29
src/helpers/magic-textarea.tsx
Normal file
29
src/helpers/magic-textarea.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { RefType } from "../components/magic-textarea";
|
||||
|
||||
export default function insertTextIntoMagicTextarea(
|
||||
instance: RefType,
|
||||
getText: () => string,
|
||||
setText: (text: string) => void,
|
||||
text: string,
|
||||
) {
|
||||
const content = getText();
|
||||
const position = instance.getCaretPosition();
|
||||
|
||||
if (position !== undefined) {
|
||||
let inject = text;
|
||||
|
||||
// add a space before
|
||||
if (position >= 1 && content.slice(position - 1, position) !== " ") inject = " " + inject;
|
||||
// add a space after
|
||||
if (position < content.length && content.slice(position, position + 1) !== " ") inject = inject + " ";
|
||||
|
||||
setText(content.slice(0, position) + inject + content.slice(position));
|
||||
} else {
|
||||
let inject = text;
|
||||
|
||||
// add a space before if there isn't one
|
||||
if (content.slice(content.length - 1) !== " ") inject = " " + inject;
|
||||
|
||||
setText(content + inject + " ");
|
||||
}
|
||||
}
|
@ -8,7 +8,5 @@ export default function useSearchRelays() {
|
||||
const searchRelayList = useUserSearchRelayList(account?.pubkey);
|
||||
const searchRelays = searchRelayList ? getRelaysFromList(searchRelayList) : DEFAULT_SEARCH_RELAYS;
|
||||
|
||||
// TODO: maybe add localRelay into the list if it supports NIP-50
|
||||
|
||||
return searchRelays;
|
||||
}
|
||||
|
@ -10,56 +10,43 @@ import useUsersMediaServers from "./use-user-media-servers";
|
||||
import { simpleMultiServerUpload } from "../helpers/media-upload/blossom";
|
||||
import useCurrentAccount from "./use-current-account";
|
||||
import { stripSensitiveMetadataOnFile } from "../helpers/image";
|
||||
import insertTextIntoMagicTextarea from "../helpers/magic-textarea";
|
||||
|
||||
export function useTextAreaUploadFileWithForm(
|
||||
ref: MutableRefObject<RefType | null>,
|
||||
getValues: UseFormGetValues<any>,
|
||||
setValue: UseFormSetValue<any>,
|
||||
) {
|
||||
const getText = useCallback(() => getValues().content, [getValues]);
|
||||
const setText = useCallback(
|
||||
(text: string) => setValue("content", text, { shouldDirty: true, shouldTouch: true }),
|
||||
[setValue],
|
||||
);
|
||||
return useTextAreaUploadFile(ref, getText, setText);
|
||||
const insertText = useTextAreaInsertTextWithForm(ref, getValues, setValue);
|
||||
return useTextAreaUploadFile(insertText);
|
||||
}
|
||||
|
||||
export default function useTextAreaUploadFile(
|
||||
export function useTextAreaInsertTextWithForm(
|
||||
ref: MutableRefObject<RefType | null>,
|
||||
getText: () => string,
|
||||
setText: (text: string) => void,
|
||||
getValues: UseFormGetValues<any>,
|
||||
setValue: UseFormSetValue<any>,
|
||||
field = "content",
|
||||
) {
|
||||
const getText = useCallback(() => getValues()[field], [getValues, field]);
|
||||
const setText = useCallback(
|
||||
(text: string) => setValue(field, text, { shouldDirty: true, shouldTouch: true }),
|
||||
[setValue, field],
|
||||
);
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
if (ref.current) insertTextIntoMagicTextarea(ref.current, getText, setText, text);
|
||||
},
|
||||
[setText, getText],
|
||||
);
|
||||
}
|
||||
|
||||
export default function useTextAreaUploadFile(insertText: (url: string) => void) {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount();
|
||||
const { mediaUploadService } = useAppSettings();
|
||||
const { servers: mediaServers } = useUsersMediaServers(account?.pubkey);
|
||||
const { requestSignature } = useSigningContext();
|
||||
|
||||
const insertURL = useCallback(
|
||||
(url: string) => {
|
||||
const content = getText();
|
||||
const position = ref.current?.getCaretPosition();
|
||||
if (position !== undefined) {
|
||||
let inject = url;
|
||||
|
||||
// add a space before
|
||||
if (position >= 1 && content.slice(position - 1, position) !== " ") inject = " " + inject;
|
||||
// add a space after
|
||||
if (position < content.length && content.slice(position, position + 1) !== " ") inject = inject + " ";
|
||||
|
||||
setText(content.slice(0, position) + inject + content.slice(position));
|
||||
} else {
|
||||
let inject = url;
|
||||
|
||||
// add a space before if there isn't one
|
||||
if (content.slice(content.length - 1) !== " ") inject = " " + inject;
|
||||
|
||||
setText(content + inject + " ");
|
||||
}
|
||||
},
|
||||
[setText, getText],
|
||||
);
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
@ -69,21 +56,21 @@ export default function useTextAreaUploadFile(
|
||||
if (mediaUploadService === "nostr.build") {
|
||||
const response = await nostrBuildUploadImage(safeFile, requestSignature);
|
||||
const imageUrl = response.url;
|
||||
insertURL(imageUrl);
|
||||
insertText(imageUrl);
|
||||
} else if (mediaUploadService === "blossom" && mediaServers.length) {
|
||||
const blob = await simpleMultiServerUpload(
|
||||
mediaServers.map((s) => s.toString()),
|
||||
safeFile,
|
||||
requestSignature,
|
||||
);
|
||||
insertURL(blob.url);
|
||||
insertText(blob.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
setUploading(false);
|
||||
},
|
||||
[insertURL, toast, setUploading, mediaServers, mediaUploadService],
|
||||
[insertText, toast, setUploading, mediaServers, mediaUploadService],
|
||||
);
|
||||
|
||||
const onFileInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { usePrevious, useUnmount } from "react-use";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
import { useStoreQuery } from "applesauce-react/hooks";
|
||||
import { Queries } from "applesauce-core";
|
||||
import { Filter } from "nostr-tools";
|
||||
|
||||
import timelineCacheService from "../services/timeline-cache";
|
||||
import { EventFilter } from "../classes/timeline-loader";
|
||||
import { useStoreQuery } from "applesauce-react/hooks";
|
||||
import { Queries } from "applesauce-core";
|
||||
|
||||
type Options = {
|
||||
eventFilter?: EventFilter;
|
||||
@ -14,7 +15,7 @@ type Options = {
|
||||
|
||||
export default function useTimelineLoader(
|
||||
key: string,
|
||||
relays: Iterable<string>,
|
||||
relays: Iterable<string | AbstractRelay>,
|
||||
filters: Filter | Filter[] | undefined,
|
||||
opts?: Options,
|
||||
) {
|
||||
@ -27,7 +28,11 @@ export default function useTimelineLoader(
|
||||
useEffect(() => {
|
||||
loader.setRelays(relays);
|
||||
loader.triggerChunkLoad();
|
||||
}, [Array.from(relays).join("|")]);
|
||||
}, [
|
||||
Array.from(relays)
|
||||
.map((t) => (typeof t === "string" ? t : t.url))
|
||||
.join("|"),
|
||||
]);
|
||||
|
||||
// update filters
|
||||
useEffect(() => {
|
||||
|
@ -7,7 +7,7 @@ import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import useRouteSearchValue from "../../hooks/use-route-search-value";
|
||||
|
||||
export type ListId = "following" | "global" | string;
|
||||
export type ListId = "following" | "global" | "self" | string;
|
||||
export type Person = { pubkey: string; relay?: string };
|
||||
|
||||
export type PeopleListContextType = {
|
||||
@ -34,11 +34,40 @@ function useListCoordinate(listId: ListId) {
|
||||
|
||||
return useMemo(() => {
|
||||
if (listId === "following") return account ? `${kinds.Contacts}:${account.pubkey}` : undefined;
|
||||
if (listId === "self") return undefined;
|
||||
if (listId === "global") return undefined;
|
||||
return listId;
|
||||
}, [listId, account]);
|
||||
}
|
||||
|
||||
export function usePeopleListSelect(selected: ListId, onChange: (list: ListId) => void): PeopleListContextType {
|
||||
const account = useCurrentAccount();
|
||||
|
||||
const listId = useListCoordinate(selected);
|
||||
const listEvent = useReplaceableEvent(listId, [], { alwaysRequest: true });
|
||||
|
||||
const people = listEvent && getPubkeysFromList(listEvent);
|
||||
|
||||
const filter = useMemo<Filter | undefined>(() => {
|
||||
if (selected === "global") return {};
|
||||
if (selected === "self") {
|
||||
if (account) return { authors: [account.pubkey] };
|
||||
else return {};
|
||||
}
|
||||
if (!people) return undefined;
|
||||
return { authors: people.map((p) => p.pubkey) };
|
||||
}, [people, selected, account]);
|
||||
|
||||
return {
|
||||
people,
|
||||
selected,
|
||||
listId,
|
||||
listEvent,
|
||||
setSelected: onChange,
|
||||
filter,
|
||||
};
|
||||
}
|
||||
|
||||
export type PeopleListProviderProps = PropsWithChildren & {
|
||||
initList?: ListId;
|
||||
};
|
||||
@ -54,28 +83,7 @@ export default function PeopleListProvider({ children, initList }: PeopleListPro
|
||||
[peopleParam.setValue],
|
||||
);
|
||||
|
||||
const listId = useListCoordinate(selected);
|
||||
const listEvent = useReplaceableEvent(listId, [], { alwaysRequest: true });
|
||||
|
||||
const people = listEvent && getPubkeysFromList(listEvent);
|
||||
|
||||
const filter = useMemo<Filter | undefined>(() => {
|
||||
if (selected === "global") return {};
|
||||
if (!people) return undefined;
|
||||
return { authors: people.map((p) => p.pubkey) };
|
||||
}, [people, selected]);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
people,
|
||||
selected,
|
||||
listId,
|
||||
listEvent,
|
||||
setSelected,
|
||||
filter,
|
||||
}),
|
||||
[selected, setSelected, people, listEvent],
|
||||
);
|
||||
const context = usePeopleListSelect(selected, setSelected);
|
||||
|
||||
return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>;
|
||||
}
|
||||
|
@ -1,16 +1,18 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import dayjs from "dayjs";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import { Button, Flex, FlexProps, Heading } from "@chakra-ui/react";
|
||||
import { Button, ButtonGroup, Flex, FlexProps, Heading } from "@chakra-ui/react";
|
||||
|
||||
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
|
||||
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
|
||||
import { createEmojiTags, ensureNotifyPubkeys, getPubkeysMentionedInContent } from "../../../helpers/nostr/post";
|
||||
import { useContextEmojis } from "../../../providers/global/emoji-provider";
|
||||
import { usePublishEvent } from "../../../providers/global/publish-provider";
|
||||
import InsertGifButton from "../../../components/gif/insert-gif-button";
|
||||
import InsertImageButton from "../../../components/post-modal/insert-image-button";
|
||||
|
||||
export default function ChannelMessageForm({
|
||||
channel,
|
||||
@ -31,7 +33,8 @@ export default function ChannelMessageForm({
|
||||
|
||||
const componentRef = useRef<RefType | null>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const { onPaste } = useTextAreaUploadFileWithForm(componentRef, getValues, setValue);
|
||||
const insertText = useTextAreaInsertTextWithForm(componentRef, getValues, setValue);
|
||||
const { onPaste } = useTextAreaUploadFile(insertText);
|
||||
|
||||
const sendMessage = handleSubmit(async (values) => {
|
||||
if (!values.content) return;
|
||||
@ -79,7 +82,13 @@ export default function ChannelMessageForm({
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && formRef.current) formRef.current.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
<Button type="submit">Send</Button>
|
||||
<Flex gap="2" direction="column">
|
||||
<Button type="submit">Send</Button>
|
||||
<ButtonGroup size="sm">
|
||||
<InsertImageButton onUploaded={insertText} aria-label="Upload image" />
|
||||
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
@ -6,12 +6,13 @@ import { kinds } from "nostr-tools";
|
||||
import { Button, Flex, FlexProps, Heading } from "@chakra-ui/react";
|
||||
import { useSigningContext } from "../../../providers/global/signing-provider";
|
||||
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
|
||||
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import { DraftNostrEvent } from "../../../types/nostr-event";
|
||||
import useUserMailboxes from "../../../hooks/use-user-mailboxes";
|
||||
import { usePublishEvent } from "../../../providers/global/publish-provider";
|
||||
import useCacheForm from "../../../hooks/use-cache-form";
|
||||
import decryptionCacheService from "../../../services/decryption-cache";
|
||||
import InsertGifButton from "../../../components/gif/insert-gif-button";
|
||||
|
||||
export default function SendMessageForm({
|
||||
pubkey,
|
||||
@ -36,7 +37,8 @@ export default function SendMessageForm({
|
||||
|
||||
const autocompleteRef = useRef<RefType | null>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const { onPaste } = useTextAreaUploadFileWithForm(autocompleteRef, getValues, setValue);
|
||||
const insertText = useTextAreaInsertTextWithForm(autocompleteRef, getValues, setValue);
|
||||
const { onPaste } = useTextAreaUploadFile(insertText);
|
||||
|
||||
const userMailboxes = useUserMailboxes(pubkey);
|
||||
const sendMessage = handleSubmit(async (values) => {
|
||||
@ -96,7 +98,10 @@ export default function SendMessageForm({
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && formRef.current) formRef.current.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
<Button type="submit">Send</Button>
|
||||
<Flex gap="2" direction="column">
|
||||
<Button type="submit">Send</Button>
|
||||
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
35
src/views/search/components/search-relay-picker.tsx
Normal file
35
src/views/search/components/search-relay-picker.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Select, SelectProps } from "@chakra-ui/react";
|
||||
import { AbstractRelay } from "nostr-tools/abstract-relay";
|
||||
|
||||
import useSearchRelays from "../../../hooks/use-search-relays";
|
||||
import { useRelayInfo } from "../../../hooks/use-relay-info";
|
||||
import { localRelay } from "../../../services/local-relay";
|
||||
import WasmRelay from "../../../services/wasm-relay";
|
||||
import relayPoolService from "../../../services/relay-pool";
|
||||
|
||||
export function useSearchRelay(relay?: string) {
|
||||
if (!relay) return undefined;
|
||||
if (relay === "local") return localRelay as AbstractRelay;
|
||||
else return relayPoolService.requestRelay(relay);
|
||||
}
|
||||
|
||||
const SearchRelayPicker = forwardRef<any, Omit<SelectProps, "children">>(({ value, onChange, ...props }) => {
|
||||
const searchRelays = useSearchRelays();
|
||||
const { info: localRelayInfo } = useRelayInfo(localRelay instanceof AbstractRelay ? localRelay : undefined, true);
|
||||
const localSearchSupported =
|
||||
localRelay instanceof WasmRelay ||
|
||||
(localRelay instanceof AbstractRelay && !!localRelayInfo?.supported_nips?.includes(50));
|
||||
|
||||
return (
|
||||
<Select w="auto" value={value} onChange={onChange} {...props}>
|
||||
{localSearchSupported && <option value="local">Local Relay</option>}
|
||||
{searchRelays.map((url) => (
|
||||
<option key={url} value={url}>
|
||||
{url}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
export default SearchRelayPicker;
|
@ -12,7 +12,8 @@ import { useUserInbox } from "../../../../hooks/use-user-mailboxes";
|
||||
import { usePublishEvent } from "../../../../providers/global/publish-provider";
|
||||
import { useReadRelays } from "../../../../hooks/use-client-relays";
|
||||
import { useAdditionalRelayContext } from "../../../../providers/local/additional-relay-context";
|
||||
import { useTextAreaUploadFileWithForm } from "../../../../hooks/use-textarea-upload-file";
|
||||
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../../hooks/use-textarea-upload-file";
|
||||
import InsertGifButton from "../../../../components/gif/insert-gif-button";
|
||||
|
||||
export default function ChatMessageForm({ stream, hideZapButton }: { stream: ParsedStream; hideZapButton?: boolean }) {
|
||||
const toast = useToast();
|
||||
@ -35,7 +36,8 @@ export default function ChatMessageForm({ stream, hideZapButton }: { stream: Par
|
||||
});
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const { onPaste } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
|
||||
const insertText = useTextAreaInsertTextWithForm(textAreaRef, getValues, setValue);
|
||||
const { onPaste } = useTextAreaUploadFile(insertText);
|
||||
|
||||
watch("content");
|
||||
|
||||
@ -43,6 +45,7 @@ export default function ChatMessageForm({ stream, hideZapButton }: { stream: Par
|
||||
<>
|
||||
<Box borderRadius="md" flexShrink={0} display="flex" gap="2" px="2" pb="2">
|
||||
<Flex as="form" onSubmit={sendMessage} gap="2" flex={1}>
|
||||
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
|
||||
<MagicInput
|
||||
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||
placeholder="Message"
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Box, Button, ButtonGroup, Flex, IconButton, VisuallyHiddenInput } from "@chakra-ui/react";
|
||||
import { Box, Button, ButtonGroup, Flex } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useThrottle } from "react-use";
|
||||
import { kinds } from "nostr-tools";
|
||||
@ -20,12 +20,13 @@ import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
|
||||
import { useContextEmojis } from "../../../providers/global/emoji-provider";
|
||||
import { TrustProvider } from "../../../providers/local/trust-provider";
|
||||
import { UploadImageIcon } from "../../../components/icons";
|
||||
import { unique } from "../../../helpers/array";
|
||||
import { usePublishEvent } from "../../../providers/global/publish-provider";
|
||||
import { TextNoteContents } from "../../../components/note/timeline-note/text-note-contents";
|
||||
import useCacheForm from "../../../hooks/use-cache-form";
|
||||
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||
import InsertGifButton from "../../../components/gif/insert-gif-button";
|
||||
import InsertImageButton from "../../../components/post-modal/insert-image-button";
|
||||
|
||||
export type ReplyFormProps = {
|
||||
item: ThreadItem;
|
||||
@ -55,8 +56,8 @@ export default function ReplyForm({ item, onCancel, onSubmitted, replyKind = kin
|
||||
watch("content");
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const { onPaste, onFileInputChange, uploading } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
|
||||
const insertText = useTextAreaInsertTextWithForm(textAreaRef, getValues, setValue);
|
||||
const { onPaste } = useTextAreaUploadFile(insertText);
|
||||
|
||||
const draft = useMemo(() => {
|
||||
let updated = finalizeNote({ kind: replyKind, content: getValues().content, created_at: dayjs().unix(), tags: [] });
|
||||
@ -93,15 +94,8 @@ export default function ReplyForm({ item, onCancel, onSubmitted, replyKind = kin
|
||||
}}
|
||||
/>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<VisuallyHiddenInput type="file" accept="image/*" ref={imageUploadRef} onChange={onFileInputChange} />
|
||||
<IconButton
|
||||
icon={<UploadImageIcon />}
|
||||
aria-label="Upload Image"
|
||||
title="Upload Image"
|
||||
onClick={() => imageUploadRef.current?.click()}
|
||||
isLoading={uploading}
|
||||
size="sm"
|
||||
/>
|
||||
<InsertImageButton onUploaded={insertText} size="sm" aria-label="Upload image" />
|
||||
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" size="sm" />
|
||||
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
{onCancel && <Button onClick={onCancel}>Cancel</Button>}
|
||||
|
Loading…
x
Reference in New Issue
Block a user