Merge pull request #281 from kiuusai/upload-profile-and-banner-images

Profile and banner images upload implementation
This commit is contained in:
hzrd149 2025-02-13 16:46:01 -06:00 committed by GitHub
commit b1b1cab2e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 167 additions and 56 deletions

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
pnpm 9.15.5
nodejs 20.18.0

View File

@ -0,0 +1,37 @@
import { ChangeEventHandler, ClipboardEventHandler, useCallback } from "react";
import useUploadFile from "./use-upload-file";
import { UseFormSetValue } from "react-hook-form";
export function useInputUploadFileWithForm(setValue: UseFormSetValue<any>, field: string) {
const setText = useCallback((text: string) => setValue(field, text), [setValue]);
return useInputUploadFile(setText);
}
export default function useInputUploadFile(setText: (text: string) => void) {
const { uploadFile, uploading } = useUploadFile();
const privateUploadFile = useCallback(async (file: File) => {
const imageUrl = await uploadFile(file);
if (imageUrl)
setText(imageUrl);
}, [uploadFile])
const onFileInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
const img = e.target.files?.[0];
if (img) privateUploadFile(img);
},
[privateUploadFile],
);
const onPaste = useCallback<ClipboardEventHandler<HTMLInputElement>>(
(e) => {
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (imageFile) privateUploadFile(imageFile);
},
[privateUploadFile],
);
return { uploadFile, uploading, onPaste, onFileInputChange };
}

View File

@ -1,16 +1,9 @@
import { ChangeEventHandler, ClipboardEventHandler, MutableRefObject, useCallback, useState } from "react";
import { useToast } from "@chakra-ui/react";
import { useActiveAccount } from "applesauce-react/hooks";
import { nostrBuildUploadImage } from "../helpers/media-upload/nostr-build";
import { RefType } from "../components/magic-textarea";
import { useSigningContext } from "../providers/global/signing-provider";
import { UseFormGetValues, UseFormSetValue } from "react-hook-form";
import useAppSettings from "./use-user-app-settings";
import useUsersMediaServers from "./use-user-media-servers";
import { simpleMultiServerUpload } from "../helpers/media-upload/blossom";
import { stripSensitiveMetadataOnFile } from "../helpers/image";
import insertTextIntoMagicTextarea from "../helpers/magic-textarea";
import useUploadFile from "./use-upload-file";
export function useTextAreaUploadFileWithForm(
ref: MutableRefObject<RefType | null>,
@ -41,52 +34,29 @@ export function useTextAreaInsertTextWithForm(
}
export default function useTextAreaUploadFile(insertText: (url: string) => void) {
const toast = useToast();
const account = useActiveAccount();
const { mediaUploadService } = useAppSettings();
const { servers: mediaServers } = useUsersMediaServers(account?.pubkey);
const { requestSignature } = useSigningContext();
const { uploadFile, uploading } = useUploadFile();
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
setUploading(true);
try {
const safeFile = await stripSensitiveMetadataOnFile(file);
if (mediaUploadService === "nostr.build") {
const response = await nostrBuildUploadImage(safeFile, requestSignature);
const imageUrl = response.url;
insertText(imageUrl);
} else if (mediaUploadService === "blossom" && mediaServers.length) {
const blob = await simpleMultiServerUpload(
mediaServers.map((s) => s.toString()),
safeFile,
requestSignature,
);
insertText(blob.url);
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setUploading(false);
},
[insertText, toast, setUploading, mediaServers, mediaUploadService],
);
const privateUploadFile = useCallback(async (file: File) => {
const imageUrl = await uploadFile(file);
if (imageUrl)
insertText(imageUrl);
}, [uploadFile])
const onFileInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
const img = e.target.files?.[0];
if (img) uploadFile(img);
if (img) privateUploadFile(img);
},
[uploadFile],
[privateUploadFile],
);
const onPaste = useCallback<ClipboardEventHandler<HTMLTextAreaElement>>(
(e) => {
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (imageFile) uploadFile(imageFile);
if (imageFile) privateUploadFile(imageFile);
},
[uploadFile],
[privateUploadFile],
);
return { uploadFile, uploading, onPaste, onFileInputChange };

View File

@ -0,0 +1,52 @@
import { useCallback, useState } from "react";
import { stripSensitiveMetadataOnFile } from "../helpers/image";
import { nostrBuildUploadImage } from "../helpers/media-upload/nostr-build";
import { useToast } from "@chakra-ui/react";
import useUsersMediaServers from "./use-user-media-servers";
import { useSigningContext } from "../providers/global/signing-provider";
import { useActiveAccount } from "applesauce-react/hooks";
import useAppSettings from "./use-user-app-settings";
import { simpleMultiServerUpload } from "~/helpers/media-upload/blossom";
export default function useUploadFile() {
const toast = useToast();
const account = useActiveAccount();
const { mediaUploadService } = useAppSettings();
const { servers: mediaServers } = useUsersMediaServers(account?.pubkey);
const { requestSignature } = useSigningContext();
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
let imageUrl: string | undefined = undefined;
setUploading(true);
try {
const safeFile = await stripSensitiveMetadataOnFile(file);
if (mediaUploadService === "nostr.build") {
const response = await nostrBuildUploadImage(safeFile, requestSignature);
imageUrl = response.url;
} else if (mediaUploadService === "blossom" && mediaServers.length) {
const blob = await simpleMultiServerUpload(
mediaServers.map((s) => s.toString()),
safeFile,
requestSignature,
);
imageUrl = blob.url;
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setUploading(false);
return imageUrl;
},
[toast, setUploading, mediaServers, mediaUploadService],
);
return {
uploadFile,
uploading,
};
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import {
Avatar,
Button,
@ -9,11 +9,15 @@ import {
Input,
Link,
Textarea,
InputGroup,
InputRightElement,
IconButton,
VisuallyHiddenInput,
} from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { ProfileContent, unixNow } from "applesauce-core/helpers";
import { ExternalLinkIcon } from "../../components/icons";
import { ExternalLinkIcon, OutboxIcon } from "../../components/icons";
import { isLNURL } from "../../helpers/lnurl";
import { useReadRelays } from "../../hooks/use-client-relays";
import { useActiveAccount } from "applesauce-react/hooks";
@ -24,6 +28,7 @@ import lnurlMetadataService from "../../services/lnurl-metadata";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { COMMON_CONTACT_RELAYS } from "../../const";
import { usePublishEvent } from "../../providers/global/publish-provider";
import { useInputUploadFileWithForm } from "../../hooks/use-input-upload-file";
const isEmail =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
@ -55,6 +60,7 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
reset,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormData>({
mode: "onBlur",
@ -65,6 +71,12 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
reset(defaultValues);
}, [defaultValues]);
const pictureUploadManage = useInputUploadFileWithForm(setValue, "picture");
const pictureUploadRef = useRef<HTMLInputElement | null>(null);
const bannerUploadManage = useInputUploadFileWithForm(setValue, "banner");
const bannerUploadRef = useRef<HTMLInputElement | null>(null);
return (
<VerticalPageLayout as="form" onSubmit={handleSubmit(onSubmit)}>
<Flex gap="2">
@ -113,24 +125,62 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
<Flex gap="2" alignItems="center">
<FormControl isInvalid={!!errors.picture}>
<FormLabel>Picture</FormLabel>
<Input
autoComplete="off"
isDisabled={isSubmitting}
placeholder="https://domain.com/path/picture.png"
{...register("picture", { maxLength: 150 })}
/>
<InputGroup>
<Input
onPaste={pictureUploadManage.onPaste}
autoComplete="off"
isDisabled={isSubmitting}
placeholder="https://domain.com/path/picture.png"
{...register("picture", { maxLength: 150 })}
/>
<InputRightElement>
<IconButton
isLoading={pictureUploadManage.uploading}
size="sm"
icon={<OutboxIcon />}
title="Upload picture"
aria-label="Upload picture"
onClick={() => pictureUploadRef.current?.click()}
/>
</InputRightElement>
<VisuallyHiddenInput
type="file"
accept="image/*"
ref={pictureUploadRef}
onChange={pictureUploadManage.onFileInputChange}
/>
</InputGroup>
</FormControl>
<Avatar src={watch("picture")} size="lg" ignoreFallback />
</Flex>
<Flex gap="2" alignItems="center">
<FormControl isInvalid={!!errors.banner}>
<FormLabel>Banner</FormLabel>
<Input
autoComplete="off"
isDisabled={isSubmitting}
placeholder="https://domain.com/path/banner.png"
{...register("banner", { maxLength: 150 })}
/>
<InputGroup>
<Input
onPaste={bannerUploadManage.onPaste}
autoComplete="off"
isDisabled={isSubmitting}
placeholder="https://domain.com/path/banner.png"
{...register("banner", { maxLength: 150 })}
/>
<InputRightElement>
<IconButton
isLoading={bannerUploadManage.uploading}
size="sm"
icon={<OutboxIcon />}
title="Upload baner"
aria-label="Upload banner"
onClick={() => bannerUploadRef.current?.click()}
/>
</InputRightElement>
<VisuallyHiddenInput
type="file"
accept="image/*"
ref={bannerUploadRef}
onChange={bannerUploadManage.onFileInputChange}
/>
</InputGroup>
</FormControl>
<Avatar src={watch("banner")} size="lg" ignoreFallback />
</Flex>