mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-11 13:20:37 +02:00
Add nostr.build image uploads
This commit is contained in:
parent
fc8058addc
commit
02ea06ae59
5
.changeset/quiet-clocks-sell.md
Normal file
5
.changeset/quiet-clocks-sell.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add nostr.build image uploads
|
@ -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",
|
||||
|
@ -35,6 +35,7 @@ export function embedNostrLinks(content: EmbedableContent) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function embedNostrMentions(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
|
||||
return embedJSX(content, {
|
||||
name: "nostr-mention",
|
||||
|
@ -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
|
||||
|
@ -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 />}
|
||||
|
54
src/helpers/nostr-build.ts
Normal file
54
src/helpers/nostr-build.ts
Normal 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];
|
||||
}
|
@ -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";
|
||||
|
@ -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 = {
|
||||
|
@ -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 />}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user