Add nostr.build image uploads

This commit is contained in:
hzrd149
2023-09-23 10:02:24 -05:00
parent fc8058addc
commit 02ea06ae59
11 changed files with 895 additions and 837 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add nostr.build image uploads

View File

@@ -36,7 +36,7 @@
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"noble-secp256k1": "^1.2.14", "noble-secp256k1": "^1.2.14",
"nostr-tools": "^1.14.0", "nostr-tools": "^1.15.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^4.0.11", "react-error-boundary": "^4.0.11",

View File

@@ -35,6 +35,7 @@ export function embedNostrLinks(content: EmbedableContent) {
}); });
} }
/** @deprecated */
export function embedNostrMentions(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) { export function embedNostrMentions(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
return embedJSX(content, { return embedJSX(content, {
name: "nostr-mention", name: "nostr-mention",

View File

@@ -1,5 +1,5 @@
import React, { TextareaHTMLAttributes } from "react"; import React, { LegacyRef } from "react";
import { Image, Input, InputProps, Textarea, TextareaProps } from "@chakra-ui/react"; import { Image, InputProps, Textarea, TextareaProps, Input } from "@chakra-ui/react";
import ReactTextareaAutocomplete, { import ReactTextareaAutocomplete, {
ItemComponentProps, ItemComponentProps,
TextareaProps as ReactTextareaAutocompleteProps, TextareaProps as ReactTextareaAutocompleteProps,
@@ -96,14 +96,18 @@ function useAutocompleteTriggers() {
return triggers; return triggers;
} }
export function MagicInput({ ...props }: InputProps) { // @ts-ignore
export type RefType = ReactTextareaAutocomplete<Token, TextareaProps>;
export function MagicInput({ instanceRef, ...props }: InputProps & { instanceRef?: LegacyRef<RefType> }) {
const triggers = useAutocompleteTriggers(); const triggers = useAutocompleteTriggers();
return ( return (
// @ts-ignore // @ts-ignore
<ReactTextareaAutocomplete<Token, InputProps> <ReactTextareaAutocomplete<Token, InputProps>
textAreaComponent={Input}
{...props} {...props}
textAreaComponent={Input}
ref={instanceRef}
loadingComponent={Loading} loadingComponent={Loading}
renderToBody renderToBody
minChar={0} minChar={0}
@@ -112,13 +116,14 @@ export function MagicInput({ ...props }: InputProps) {
); );
} }
export default function MagicTextArea({ ...props }: TextareaProps) { export default function MagicTextArea({ instanceRef, ...props }: TextareaProps & { instanceRef?: LegacyRef<RefType> }) {
const triggers = useAutocompleteTriggers(); const triggers = useAutocompleteTriggers();
return ( return (
// @ts-ignore // @ts-ignore
<ReactTextareaAutocomplete<Token, TextareaProps> <ReactTextareaAutocomplete<Token, TextareaProps>
{...props} {...props}
ref={instanceRef}
textAreaComponent={Textarea} textAreaComponent={Textarea}
loadingComponent={Loading} loadingComponent={Loading}
renderToBody renderToBody

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { import {
Modal, Modal,
ModalOverlay, ModalOverlay,
@@ -13,6 +13,8 @@ import {
Input, Input,
Switch, Switch,
ModalProps, ModalProps,
VisuallyHiddenInput,
IconButton,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -21,14 +23,15 @@ import { Kind } from "nostr-tools";
import NostrPublishAction from "../../classes/nostr-publish-action"; import NostrPublishAction from "../../classes/nostr-publish-action";
import { useWriteRelayUrls } from "../../hooks/use-client-relays"; import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useSigningContext } from "../../providers/signing-provider"; import { useSigningContext } from "../../providers/signing-provider";
import { ArrowDownSIcon, ArrowUpSIcon } from "../icons"; import { ArrowDownSIcon, ArrowUpSIcon, ImageIcon } from "../icons";
import { NoteContents } from "../note/note-contents"; import { NoteContents } from "../note/note-contents";
import { PublishDetails } from "../publish-details"; import { PublishDetails } from "../publish-details";
import { TrustProvider } from "../../providers/trust"; import { TrustProvider } from "../../providers/trust";
import { createEmojiTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post"; import { createEmojiTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post";
import { UserAvatarStack } from "../compact-user-stack"; import { UserAvatarStack } from "../compact-user-stack";
import MagicTextArea from "../magic-textarea"; import MagicTextArea, { RefType } from "../magic-textarea";
import { useContextEmojis } from "../../providers/emoji-provider"; import { useContextEmojis } from "../../providers/emoji-provider";
import { nostrBuildUploadImage } from "../../helpers/nostr-build";
export default function PostModal({ export default function PostModal({
isOpen, isOpen,
@@ -53,26 +56,28 @@ export default function PostModal({
watch("nsfw"); watch("nsfw");
watch("nsfwReason"); watch("nsfwReason");
// const imageUploadRef = useRef<HTMLInputElement | null>(null); const textAreaRef = useRef<RefType | null>(null);
// const [uploading, setUploading] = useState(false);
// const uploadImage = async (imageFile: File) => { const imageUploadRef = useRef<HTMLInputElement | null>(null);
// try { const [uploading, setUploading] = useState(false);
// if (!imageFile.type.includes("image")) throw new Error("Only images are supported"); const uploadImage = async (imageFile: File) => {
// setUploading(true); try {
// const payload = new FormData(); if (!imageFile.type.includes("image")) throw new Error("Only images are supported");
// payload.append("fileToUpload", imageFile); setUploading(true);
// const response = await fetch("https://nostr.build/upload.php", { body: payload, method: "POST" }).then((res) =>
// res.text(), const response = await nostrBuildUploadImage(imageFile, requestSignature);
// ); const imageUrl = response.url;
// const imageUrl = response.match(/https:\/\/nostr\.build\/i\/[\w.]+/)?.[0];
// if (imageUrl) { const content = getValues().content;
// setValue('content', getValues().content += imageUrl ); const position = textAreaRef.current?.getCaretPosition();
// } if (position !== undefined) {
// } catch (e) { setValue("content", content.slice(0, position) + imageUrl + content.slice(position));
// if (e instanceof Error) toast({ description: e.message, status: "error" }); } else setValue("content", content + imageUrl);
// } } catch (e) {
// setUploading(false); if (e instanceof Error) toast({ description: e.message, status: "error" });
// }; }
setUploading(false);
};
const getDraft = useCallback(() => { const getDraft = useCallback(() => {
const { content, nsfw, nsfwReason } = getValues(); const { content, nsfw, nsfwReason } = getValues();
@@ -127,10 +132,11 @@ export default function PostModal({
onChange={(e) => setValue("content", e.target.value)} onChange={(e) => setValue("content", e.target.value)}
rows={5} rows={5}
isRequired isRequired
// onPaste={(e) => { instanceRef={(inst) => (textAreaRef.current = inst)}
// const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image")); onPaste={(e) => {
// if (imageFile) uploadImage(imageFile); const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
// }} if (imageFile) uploadImage(imageFile);
}}
/> />
{getValues().content.length > 0 && ( {getValues().content.length > 0 && (
<Box> <Box>
@@ -144,7 +150,7 @@ export default function PostModal({
)} )}
<Flex gap="2" alignItems="center" justifyContent="flex-end"> <Flex gap="2" alignItems="center" justifyContent="flex-end">
<Flex mr="auto" gap="2"> <Flex mr="auto" gap="2">
{/* <VisuallyHiddenInput <VisuallyHiddenInput
type="file" type="file"
accept="image/*" accept="image/*"
ref={imageUploadRef} ref={imageUploadRef}
@@ -159,7 +165,7 @@ export default function PostModal({
title="Upload Image" title="Upload Image"
onClick={() => imageUploadRef.current?.click()} onClick={() => imageUploadRef.current?.click()}
isLoading={uploading} isLoading={uploading}
/> */} />
<Button <Button
variant="link" variant="link"
rightIcon={moreOptions.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />} rightIcon={moreOptions.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}

View File

@@ -0,0 +1,54 @@
import { nip98 } from "nostr-tools";
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
type NostrBuildResponse = {
status: "success" | "error";
message: string;
data: [
{
input_name: "APIv2";
name: string;
url: string;
thumbnail: string;
responsive: {
"240p": string;
"360p": string;
"480p": string;
"720p": string;
"1080p": string;
};
blurhash: string;
sha256: string;
type: "picture" | "video";
mime: string;
size: number;
metadata: Record<string, string>;
dimensions: {
width: number;
height: number;
};
},
];
};
export async function nostrBuildUploadImage(image: File, sign?: (draft: DraftNostrEvent) => Promise<NostrEvent>) {
if (!image.type.includes("image")) throw new Error("Only images are supported");
const url = "https://nostr.build/api/v2/upload/files";
const payload = new FormData();
payload.append("fileToUpload", image);
const headers: HeadersInit = {};
if (sign) {
// @ts-ignore
const token = await nip98.getToken(url, "post", sign, true);
headers.Authorization = token;
}
const response = await fetch(url, { body: payload, method: "POST", headers }).then(
(res) => res.json() as Promise<NostrBuildResponse>,
);
return response.data[0];
}

View File

@@ -1,6 +1,5 @@
import { DraftNostrEvent, NostrEvent, PTag, Tag } from "../../types/nostr-event"; import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event";
import { getMatchEmoji, getMatchHashtag } from "../regexp"; import { getMatchEmoji, getMatchHashtag } from "../regexp";
import { normalizeToHex } from "../nip19";
import { getReferences } from "./events"; import { getReferences } from "./events";
import { getEventRelays } from "../../services/event-relays"; import { getEventRelays } from "../../services/event-relays";
import relayScoreboardService from "../../services/relay-scoreboard"; import relayScoreboardService from "../../services/relay-scoreboard";

View File

@@ -1,8 +1,8 @@
import { NostrEvent } from "../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { EventReferences, getReferences } from "./nostr/events"; import { EventReferences, getReferences } from "./nostr/events";
export function countReplies(thread: ThreadItem): number { export function countReplies(replies: ThreadItem[]): number {
return thread.replies.reduce((c, item) => c + countReplies(item), 0) + thread.replies.length; return replies.reduce((c, item) => c + countReplies(item.replies), 0) + replies.length;
} }
export type ThreadItem = { export type ThreadItem = {

View File

@@ -20,16 +20,13 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
const showReplyForm = useDisclosure(); const showReplyForm = useDisclosure();
const muteFilter = useClientSideMuteFilter(); const muteFilter = useClientSideMuteFilter();
const [alwaysShow, setAlwaysShow] = useState(false);
const numberOfReplies = countReplies(post); const replies = post.replies.filter((r) => !muteFilter(r.event));
const numberOfReplies = countReplies(replies);
const isMuted = muteFilter(post.event); const isMuted = muteFilter(post.event);
if (isMuted && numberOfReplies === 0) return null; const [alwaysShow, setAlwaysShow] = useState(false);
const muteAlert = (
return (
<Flex direction="column" gap="2">
{isMuted && !alwaysShow ? (
<Alert status="warning"> <Alert status="warning">
<AlertIcon /> <AlertIcon />
Muted user or note Muted user or note
@@ -37,6 +34,14 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
Show anyway Show anyway
</Button> </Button>
</Alert> </Alert>
);
if (isMuted && replies.length === 0) return null;
return (
<Flex direction="column" gap="2">
{isMuted && !alwaysShow ? (
muteAlert
) : ( ) : (
<TrustProvider trust={focusId === post.event.id ? true : undefined}> <TrustProvider trust={focusId === post.event.id ? true : undefined}>
<Note event={post.event} borderColor={focusId === post.event.id ? "blue.500" : undefined} hideDrawerButton /> <Note event={post.event} borderColor={focusId === post.event.id ? "blue.500" : undefined} hideDrawerButton />
@@ -52,7 +57,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
</Button> </Button>
)} )}
{post.replies.length > 0 && ( {replies.length > 0 && (
<Button onClick={toggle}> <Button onClick={toggle}>
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"} {numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
{showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />} {showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useCallback, useMemo, useRef } from "react";
import { Box, Button, Flex, useToast } from "@chakra-ui/react"; import { Box, Button, Flex, useToast } from "@chakra-ui/react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -11,8 +11,9 @@ import { useSigningContext } from "../../../../providers/signing-provider";
import NostrPublishAction from "../../../../classes/nostr-publish-action"; import NostrPublishAction from "../../../../classes/nostr-publish-action";
import { createEmojiTags, ensureNotifyContentMentions } from "../../../../helpers/nostr/post"; import { createEmojiTags, ensureNotifyContentMentions } from "../../../../helpers/nostr/post";
import { useContextEmojis } from "../../../../providers/emoji-provider"; import { useContextEmojis } from "../../../../providers/emoji-provider";
import { MagicInput } from "../../../../components/magic-textarea"; import { MagicInput, RefType } from "../../../../components/magic-textarea";
import StreamZapButton from "../../components/stream-zap-button"; import StreamZapButton from "../../components/stream-zap-button";
import { nostrBuildUploadImage } from "../../../../helpers/nostr-build";
export default function ChatMessageForm({ stream }: { stream: ParsedStream }) { export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
const toast = useToast(); const toast = useToast();
@@ -41,6 +42,27 @@ export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
} }
}); });
const textAreaRef = useRef<RefType | null>(null);
const uploadImage = useCallback(
async (imageFile: File) => {
try {
if (!imageFile.type.includes("image")) throw new Error("Only images are supported");
const response = await nostrBuildUploadImage(imageFile, requestSignature);
const imageUrl = response.url;
const content = getValues().content;
const position = textAreaRef.current?.getCaretPosition();
if (position !== undefined) {
setValue("content", content.slice(0, position) + imageUrl + content.slice(position));
} else setValue("content", content + imageUrl);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
},
[setValue, getValues],
);
watch("content"); watch("content");
return ( return (
@@ -48,11 +70,16 @@ export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
<Box borderRadius="md" flexShrink={0} display="flex" gap="2" px="2" pb="2"> <Box borderRadius="md" flexShrink={0} display="flex" gap="2" px="2" pb="2">
<Flex as="form" onSubmit={sendMessage} gap="2" flex={1}> <Flex as="form" onSubmit={sendMessage} gap="2" flex={1}>
<MagicInput <MagicInput
instanceRef={(inst) => (textAreaRef.current = inst)}
placeholder="Message" placeholder="Message"
autoComplete="off" autoComplete="off"
isRequired isRequired
value={getValues().content} value={getValues().content}
onChange={(e) => setValue("content", e.target.value)} onChange={(e) => setValue("content", e.target.value)}
onPaste={(e) => {
const file = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (file) uploadImage(file);
}}
/> />
<Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}> <Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}>
Send Send

1526
yarn.lock

File diff suppressed because it is too large Load Diff