From 5403d37a49759e07e9bcdb30f39b0bf63b703010 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Tue, 26 Nov 2024 20:01:19 -0600 Subject: [PATCH] Add insert gif button --- .changeset/strong-dolphins-happen.md | 5 + src/components/gif/gif-picker-modal.tsx | 136 ++++++++++++++++++ src/components/gif/insert-gif-button.tsx | 35 +++++ src/components/gif/single-zap-button.tsx | 46 ++++++ src/components/post-modal/index.tsx | 22 +-- .../post-modal/insert-image-button.tsx | 30 ++++ src/helpers/magic-textarea.tsx | 29 ++++ src/hooks/use-search-relays.ts | 2 - src/hooks/use-textarea-upload-file.ts | 61 ++++---- src/hooks/use-timeline-loader.ts | 13 +- src/providers/local/people-list-provider.tsx | 54 ++++--- .../channels/components/send-message-form.tsx | 19 ++- .../dms/components/send-message-form.tsx | 11 +- .../search/components/search-relay-picker.tsx | 35 +++++ .../stream/stream-chat/stream-chat-form.tsx | 7 +- src/views/thread/components/reply-form.tsx | 22 ++- 16 files changed, 422 insertions(+), 105 deletions(-) create mode 100644 .changeset/strong-dolphins-happen.md create mode 100644 src/components/gif/gif-picker-modal.tsx create mode 100644 src/components/gif/insert-gif-button.tsx create mode 100644 src/components/gif/single-zap-button.tsx create mode 100644 src/components/post-modal/insert-image-button.tsx create mode 100644 src/helpers/magic-textarea.tsx create mode 100644 src/views/search/components/search-relay-picker.tsx diff --git a/.changeset/strong-dolphins-happen.md b/.changeset/strong-dolphins-happen.md new file mode 100644 index 000000000..838b2255b --- /dev/null +++ b/.changeset/strong-dolphins-happen.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add insert gif button diff --git a/src/components/gif/gif-picker-modal.tsx b/src/components/gif/gif-picker-modal.tsx new file mode 100644 index 000000000..99e1c79f3 --- /dev/null +++ b/src/components/gif/gif-picker-modal.tsx @@ -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 & { gif: NostrEvent; onClick: () => void }) { + const url = getTagValue(gif, "url"); + const thumb = getTagValue(gif, "thumb"); + + const ref = useEventIntersectionRef(gif); + + if (!url) return null; + + return ( + + {/* + + + + */} + + + ); +} + +type GifPickerProps = Omit & { onSelect: (gif: NostrEvent) => void }; + +export default function GifPickerModal({ onClose, isOpen, onSelect, ...props }: GifPickerProps) { + const [search, setSearch] = useState(); + const [searchRelayUrl, setSearchRelayUrl] = useState(); + + const [list, setList] = useState("global"); + const { selected, setSelected, filter, listId } = usePeopleListSelect(list, setList); + + const searchRelay = useSearchRelay(searchRelayUrl); + + const [debounceSearch, setDebounceSearch] = useState(); + 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 ( + + + + + + + setSearch(e.target.value)} + /> + setSearchRelayUrl(e.target.value)} /> + + + + + + + + + + + + {timeline.map((gif) => ( + { + onSelect(gif); + onClose(); + }} + /> + ))} + + + + + + ); +} diff --git a/src/components/gif/insert-gif-button.tsx b/src/components/gif/insert-gif-button.tsx new file mode 100644 index 000000000..70b81697e --- /dev/null +++ b/src/components/gif/insert-gif-button.tsx @@ -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 & { + 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 ( + <> + } onClick={modal.onOpen} {...props} /> + {modal.isOpen && } + + ); +} diff --git a/src/components/gif/single-zap-button.tsx b/src/components/gif/single-zap-button.tsx new file mode 100644 index 000000000..74860f572 --- /dev/null +++ b/src/components/gif/single-zap-button.tsx @@ -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 & { + 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 ( + <> + } + aria-label="Zap" + title="Zap" + {...props} + onClick={onOpen} + isDisabled={!canZap} + /> + + {isOpen && ( + + )} + + ); +} diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index cc2a49def..3db6fbc99 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -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(null); const textAreaRef = useRef(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({ )} - - } - aria-label="Upload Image" - title="Upload Image" - onClick={() => imageUploadRef.current?.click()} - isLoading={uploading} - /> + + + + + + + + + )} diff --git a/src/views/dms/components/send-message-form.tsx b/src/views/dms/components/send-message-form.tsx index 2a36d46a9..7c5c73489 100644 --- a/src/views/dms/components/send-message-form.tsx +++ b/src/views/dms/components/send-message-form.tsx @@ -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(null); const textAreaRef = useRef(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(); }} /> - + + + + )} diff --git a/src/views/search/components/search-relay-picker.tsx b/src/views/search/components/search-relay-picker.tsx new file mode 100644 index 000000000..71f837eb3 --- /dev/null +++ b/src/views/search/components/search-relay-picker.tsx @@ -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>(({ 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 ( + + ); +}); +export default SearchRelayPicker; diff --git a/src/views/streams/stream/stream-chat/stream-chat-form.tsx b/src/views/streams/stream/stream-chat/stream-chat-form.tsx index 07ffe0b89..b1cb39489 100644 --- a/src/views/streams/stream/stream-chat/stream-chat-form.tsx +++ b/src/views/streams/stream/stream-chat/stream-chat-form.tsx @@ -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(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 <> + (textAreaRef.current = inst)} placeholder="Message" diff --git a/src/views/thread/components/reply-form.tsx b/src/views/thread/components/reply-form.tsx index 0798b2d20..3262d0d70 100644 --- a/src/views/thread/components/reply-form.tsx +++ b/src/views/thread/components/reply-form.tsx @@ -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(null); - const imageUploadRef = useRef(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 }} /> - - } - aria-label="Upload Image" - title="Upload Image" - onClick={() => imageUploadRef.current?.click()} - isLoading={uploading} - size="sm" - /> + + {onCancel && }