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",
"ngeohash": "^0.6.3",
"noble-secp256k1": "^1.2.14",
"nostr-tools": "^1.14.0",
"nostr-tools": "^1.15.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"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) {
return embedJSX(content, {
name: "nostr-mention",

View File

@ -1,5 +1,5 @@
import React, { TextareaHTMLAttributes } from "react";
import { Image, Input, InputProps, Textarea, TextareaProps } from "@chakra-ui/react";
import React, { LegacyRef } from "react";
import { Image, InputProps, Textarea, TextareaProps, Input } from "@chakra-ui/react";
import ReactTextareaAutocomplete, {
ItemComponentProps,
TextareaProps as ReactTextareaAutocompleteProps,
@ -96,14 +96,18 @@ function useAutocompleteTriggers() {
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();
return (
// @ts-ignore
<ReactTextareaAutocomplete<Token, InputProps>
textAreaComponent={Input}
{...props}
textAreaComponent={Input}
ref={instanceRef}
loadingComponent={Loading}
renderToBody
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();
return (
// @ts-ignore
<ReactTextareaAutocomplete<Token, TextareaProps>
{...props}
ref={instanceRef}
textAreaComponent={Textarea}
loadingComponent={Loading}
renderToBody

View File

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

View File

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

View File

@ -20,23 +20,28 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
const showReplyForm = useDisclosure();
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);
if (isMuted && numberOfReplies === 0) return null;
const [alwaysShow, setAlwaysShow] = useState(false);
const muteAlert = (
<Alert status="warning">
<AlertIcon />
Muted user or note
<Button size="xs" ml="auto" onClick={() => setAlwaysShow(true)}>
Show anyway
</Button>
</Alert>
);
if (isMuted && replies.length === 0) return null;
return (
<Flex direction="column" gap="2">
{isMuted && !alwaysShow ? (
<Alert status="warning">
<AlertIcon />
Muted user or note
<Button size="xs" ml="auto" onClick={() => setAlwaysShow(true)}>
Show anyway
</Button>
</Alert>
muteAlert
) : (
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
<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>
)}
{post.replies.length > 0 && (
{replies.length > 0 && (
<Button onClick={toggle}>
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
{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 { useForm } from "react-hook-form";
@ -11,8 +11,9 @@ import { useSigningContext } from "../../../../providers/signing-provider";
import NostrPublishAction from "../../../../classes/nostr-publish-action";
import { createEmojiTags, ensureNotifyContentMentions } from "../../../../helpers/nostr/post";
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 { nostrBuildUploadImage } from "../../../../helpers/nostr-build";
export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
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");
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">
<Flex as="form" onSubmit={sendMessage} gap="2" flex={1}>
<MagicInput
instanceRef={(inst) => (textAreaRef.current = inst)}
placeholder="Message"
autoComplete="off"
isRequired
value={getValues().content}
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}>
Send

1526
yarn.lock

File diff suppressed because it is too large Load Diff