Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: video upload #1493

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/features/post/new/PhotoPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import { IonSpinner } from "@ionic/react";

import { cx } from "#/helpers/css";
import { isUrlVideo } from "#/helpers/url";

import styles from "./PhotoPreview.module.css";

interface PhotoPreviewProps {
src: string;
loading: boolean;
onClick?: () => void;
isVideo: boolean;
}

export default function PhotoPreview({
src,
loading,
onClick,
isVideo,
}: PhotoPreviewProps) {
const Media = isVideo || isUrlVideo(src, undefined) ? "video" : "img";

return (
<div className={styles.container}>
<img
<Media
src={src}
playsInline
muted
autoPlay
onPlaying={(e) => {
if (!(e.target instanceof HTMLVideoElement)) return;

// iOS won't show preview unless the video plays
e.target.pause();
}}
onClick={onClick}
className={cx(styles.img, loading && styles.loadingImage)}
/>
Expand Down
59 changes: 33 additions & 26 deletions src/features/post/new/PostEditorRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ import { Post } from "lemmy-js-client";
import { useEffect, useMemo, useState } from "react";

import AppHeader from "#/features/shared/AppHeader";
import {
deletePendingImageUploads,
uploadImage,
} from "#/features/shared/markdown/editing/uploadImageSlice";
import { deletePendingImageUploads } from "#/features/shared/markdown/editing/uploadImageSlice";
import useUploadImage from "#/features/shared/markdown/editing/useUploadImage";
import { buildPostLink } from "#/helpers/appLinkBuilder";
import { isAndroid } from "#/helpers/device";
import { getRemoteHandle } from "#/helpers/lemmy";
Expand All @@ -49,7 +47,7 @@ import { PostEditorProps } from "./PostEditor";

import styles from "./PostEditorRoot.module.css";

type PostType = "photo" | "link" | "text";
type PostType = "media" | "link" | "text";

const MAX_TITLE_LENGTH = 200;

Expand All @@ -59,6 +57,7 @@ export default function PostEditorRoot({
...props
}: PostEditorProps) {
const dispatch = useAppDispatch();
const { uploadImage } = useUploadImage();
const [presentAlert] = useIonAlert();
const router = useOptimizedIonRouter();
const buildGeneralBrowseLink = useBuildGeneralBrowseLink();
Expand All @@ -82,9 +81,9 @@ export default function PostEditorRoot({
const initialImage = isImage ? existingPost!.post.url : undefined;

const initialPostType = (() => {
if (!existingPost) return "photo";
if (!existingPost) return "media";

if (initialImage) return "photo";
if (initialImage) return "media";

if (existingPost.post.url) return "link";

Expand Down Expand Up @@ -115,15 +114,24 @@ export default function PostEditorRoot({
const [photoPreviewURL, setPhotoPreviewURL] = useState<string | undefined>(
initialImage,
);
const [isPreviewVideo, setIsPreviewVideo] = useState(false);
const [photoUploading, setPhotoUploading] = useState(false);

const showAutofill = !!url && isValidUrl(url) && !title;

const showNsfwToggle = !!(
(postType === "photo" && photoPreviewURL) ||
(postType === "media" && photoPreviewURL) ||
(postType === "link" && url)
);

useEffect(() => {
return () => {
if (!photoPreviewURL) return;

URL.revokeObjectURL(photoPreviewURL);
};
}, [photoPreviewURL]);

useEffect(() => {
setCanDismiss(
initialPostType === postType &&
Expand Down Expand Up @@ -158,7 +166,7 @@ export default function PostEditorRoot({
if (!url) return false;
break;

case "photo":
case "media":
if (!photoUrl) return false;
break;
}
Expand All @@ -182,7 +190,7 @@ export default function PostEditorRoot({
switch (postType) {
case "link":
return url || undefined;
case "photo":
case "media":
return photoUrl || undefined;
default:
return;
Expand All @@ -194,7 +202,7 @@ export default function PostEditorRoot({
case "link":
default:
return undefined;
case "photo":
case "media":
return altText;
}
})();
Expand All @@ -211,7 +219,7 @@ export default function PostEditorRoot({
) {
errorMessage =
"Please add a valid URL to your post (start with https://).";
} else if (postType === "photo" && !photoUrl) {
} else if (postType === "media" && !photoUrl) {
errorMessage = "Please add a photo to your post.";
} else if (!canSubmit()) {
errorMessage =
Expand Down Expand Up @@ -290,6 +298,7 @@ export default function PostEditorRoot({

async function receivedImage(image: File) {
setPhotoPreviewURL(URL.createObjectURL(image));
setIsPreviewVideo(image.type.startsWith("video/"));
setPhotoUploading(true);

let imageUrl;
Expand All @@ -298,15 +307,8 @@ export default function PostEditorRoot({
if (isAndroid()) await new Promise((resolve) => setTimeout(resolve, 250));

try {
imageUrl = await dispatch(uploadImage(image));
imageUrl = await uploadImage(image);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";

presentToast({
message: `Problem uploading image: ${message}. Please try again.`,
color: "danger",
fullscreen: true,
});
clearImage();

throw error;
Expand All @@ -322,6 +324,7 @@ export default function PostEditorRoot({
function clearImage() {
setPhotoUrl("");
setPhotoPreviewURL(undefined);
setIsPreviewVideo(false);
}

async function fetchPostTitle() {
Expand Down Expand Up @@ -402,7 +405,7 @@ export default function PostEditorRoot({
value={postType}
onIonChange={(e) => setPostType(e.target.value as PostType)}
>
<IonSegmentButton value="photo">Photo</IonSegmentButton>
<IonSegmentButton value="media">Media</IonSegmentButton>
<IonSegmentButton value="link">Link</IonSegmentButton>
<IonSegmentButton value="text">Text</IonSegmentButton>
</IonSegment>
Expand Down Expand Up @@ -440,28 +443,31 @@ export default function PostEditorRoot({
</IonButton>
)}
</IonItem>
{postType === "photo" && (
{postType === "media" && (
<>
<label htmlFor="photo-upload-post">
<label htmlFor="media-upload-post">
<IonItem>
<IonLabel color="primary">
<IonIcon
className={styles.cameraIcon}
icon={cameraOutline}
/>{" "}
Choose Photo
Choose Photo / Video
</IonLabel>

<input
type="file"
accept="image/*"
id="photo-upload-post"
accept="image/*,video/webm"
id="media-upload-post"
className={styles.hiddenInput}
onInput={(e) => {
const image = (e.target as HTMLInputElement).files?.[0];
if (!image) return;

receivedImage(image);

// Allow next upload attempt
(e.target as HTMLInputElement).value = "";
}}
/>
</IonItem>
Expand All @@ -471,6 +477,7 @@ export default function PostEditorRoot({
<PhotoPreview
src={photoPreviewURL}
loading={photoUploading}
isVideo={isPreviewVideo}
/>
<IonButton
fill={altText ? "solid" : "outline"}
Expand Down
2 changes: 1 addition & 1 deletion src/features/shared/markdown/editing/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export default function Editor({
}

async function onReceivedImage(image: File) {
const markdown = await uploadImage(image);
const markdown = await uploadImage(image, true);

textareaRef.current?.focus();
insertBlock(markdown);
Expand Down
4 changes: 2 additions & 2 deletions src/features/shared/markdown/editing/modes/DefaultMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,13 @@ export default function DefaultMode({
<input
className="ion-hide"
type="file"
accept="image/*"
accept="image/*,video/webm"
id="photo-upload-toolbar"
onInput={async (e) => {
const image = (e.target as HTMLInputElement).files?.[0];
if (!image) return;

const markdown = await uploadImage(image);
const markdown = await uploadImage(image, true);

insertBlock(markdown);
}}
Expand Down
19 changes: 15 additions & 4 deletions src/features/shared/markdown/editing/useUploadImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,40 @@ export default function useUploadImage() {

return {
jsx: <IonLoading isOpen={imageUploading} message="Uploading image..." />,
uploadImage: async (image: File) => {
uploadImage: async (image: File, toMarkdown = false) => {
setImageUploading(true);

let imageUrl: string;

try {
imageUrl = await dispatch(uploadImage(image));
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
const message = (() => {
if (error instanceof Error) {
if (error.message.startsWith("NetworkError"))
return "Issue with network connectivity, or upload was too large";

return error.message;
}

return "Unknown error";
})();

presentToast({
message: `Problem uploading image: ${message}. Please try again.`,
color: "danger",
fullscreen: true,
duration: 5_000,
});

throw error;
} finally {
setImageUploading(false);
}

return `![](${imageUrl})`;
if (toMarkdown) return `![](${imageUrl})`;

return imageUrl;
},
};
}
2 changes: 2 additions & 0 deletions src/helpers/imageCompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export async function reduceFileSize(
maxHeight: number,
quality = 0.7,
): Promise<Blob | File> {
if (file.type.startsWith("video/")) return file;

if (file.size <= acceptFileSize) {
return file;
}
Expand Down
Loading