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

Add video upload #1493

Draft
wants to merge 4 commits 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
28 changes: 24 additions & 4 deletions src/features/post/new/PhotoPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { styled } from "@linaria/react";
import { IonSpinner } from "@ionic/react";
import { isUrlVideo } from "../../../helpers/url";
import { useRef } from "react";

const Container = styled.div`
position: relative;
`;

const Img = styled.img<{ loadingImage: boolean }>`
const Img = styled.video<{ loadingImage: boolean }>`
max-width: 100px;
max-height: 100px;
padding: 1rem;
Expand All @@ -23,18 +25,36 @@ const OverlaySpinner = styled(IonSpinner)`

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

export default function PhotoPreview({
src,
isVideo,
loading,
onClick,
}: PhotoPreviewProps) {
const videoTag = isVideo || isUrlVideo(src);
const ref = useRef<HTMLVideoElement>(null);

return (
<Container>
<Img src={src} onClick={onClick} loadingImage={loading} />
<Img
ref={ref}
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();
}}
loadingImage={loading}
/* Just uploaded blob (can't detect type from url), or editing post w/ media lemmy url (can) */
as={videoTag ? "video" : "img"}
/>
{loading && <OverlaySpinner />}
</Container>
);
Expand Down
56 changes: 32 additions & 24 deletions src/features/post/new/PostEditorRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@ import { useOptimizedIonRouter } from "../../../helpers/useOptimizedIonRouter";
import { isAndroid } from "../../../helpers/device";
import { css } from "@linaria/core";
import AppHeader from "../../shared/AppHeader";
import {
deletePendingImageUploads,
uploadImage,
} from "../../shared/markdown/editing/uploadImageSlice";
import { deletePendingImageUploads } from "../../shared/markdown/editing/uploadImageSlice";
import useUploadImage from "../../shared/markdown/editing/useUploadImage";

const Container = styled.div`
position: absolute;
Expand Down Expand Up @@ -81,7 +79,7 @@ const HiddenInput = styled.input`
display: none;
`;

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

const MAX_TITLE_LENGTH = 200;

Expand All @@ -106,12 +104,14 @@ export default function PostEditorRoot({

const dispatch = useAppDispatch();

const { uploadImage } = useUploadImage();

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 @@ -139,6 +139,7 @@ export default function PostEditorRoot({
const [photoPreviewURL, setPhotoPreviewURL] = useState<string | undefined>(
initialImage,
);
const [isPreviewVideo, setIsPreviewVideo] = useState(false);
const [photoUploading, setPhotoUploading] = useState(false);

const router = useOptimizedIonRouter();
Expand All @@ -147,7 +148,7 @@ export default function PostEditorRoot({
const showAutofill = !!url && isValidUrl(url) && !title;

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

Expand Down Expand Up @@ -177,6 +178,14 @@ export default function PostEditorRoot({
nsfw,
]);

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

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

function canSubmit() {
if (!title) return false;

Expand All @@ -185,7 +194,7 @@ export default function PostEditorRoot({
if (!url) return false;
break;

case "photo":
case "media":
if (!photoUrl) return false;
break;
}
Expand All @@ -209,7 +218,7 @@ export default function PostEditorRoot({
switch (postType) {
case "link":
return url || undefined;
case "photo":
case "media":
return photoUrl || undefined;
default:
return;
Expand All @@ -223,8 +232,8 @@ export default function PostEditorRoot({
} else if (postType === "link" && (!url || !validUrl(url))) {
errorMessage =
"Please add a valid URL to your post (start with https://).";
} else if (postType === "photo" && !photoUrl) {
errorMessage = "Please add a photo to your post.";
} else if (postType === "media" && !photoUrl) {
errorMessage = "Please add a photo or video to your post.";
} else if (!canSubmit()) {
errorMessage =
"It looks like you're missing some information to submit this post. Please double check.";
Expand Down Expand Up @@ -302,6 +311,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 @@ -310,15 +320,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 @@ -334,6 +337,7 @@ export default function PostEditorRoot({
function clearImage() {
setPhotoUrl("");
setPhotoPreviewURL(undefined);
setIsPreviewVideo(false);
}

async function fetchPostTitle() {
Expand Down Expand Up @@ -393,7 +397,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 @@ -430,23 +434,26 @@ export default function PostEditorRoot({
</IonButton>
)}
</IonItem>
{postType === "photo" && (
{postType === "media" && (
<>
<label htmlFor="photo-upload">
<IonItem>
<IonLabel color="primary">
<CameraIcon icon={cameraOutline} /> Choose Photo
<CameraIcon icon={cameraOutline} /> Choose Photo / Video
</IonLabel>

<HiddenInput
type="file"
accept="image/*"
accept="image/*,video/mp4"
id="photo-upload"
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 @@ -456,6 +463,7 @@ export default function PostEditorRoot({
<PhotoPreview
src={photoPreviewURL}
loading={photoUploading}
isVideo={isPreviewVideo}
/>
</IonItem>
)}
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 @@ -122,7 +122,7 @@ export default forwardRef<HTMLTextAreaElement, EditorProps>(function Editor(
}

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

textareaRef.current?.focus();
document.execCommand("insertText", false, 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 @@ -457,13 +457,13 @@ export default function DefaultMode({
display: none;
`}
type="file"
accept="image/*"
accept="image/*,video/*"
id="photo-upload"
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);

textareaRef.current?.focus();
document.execCommand("insertText", false, 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 @@ -11,29 +11,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 `\n![](${imageUrl})\n`;
if (toMarkdown) return `\n![](${imageUrl})\n`;

return imageUrl;
},
};
}
2 changes: 1 addition & 1 deletion src/helpers/imageCompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function reduceFileSize(
quality = 0.7,
): Promise<Blob | File> {
return new Promise((resolve) => {
if (file.size <= acceptFileSize) {
if (file.size <= acceptFileSize || file.type.startsWith("video/")) {
resolve(file);
return;
}
Expand Down
Loading