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

Show more videos and consolidate media logic #791

Closed
wants to merge 16 commits into from
Closed
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion src/features/comment/links/LinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -11,8 +11,8 @@ import {
import { css } from "@emotion/react";
import { getImageSrc } from "../../../services/lemmy";
import { ReactNode, useMemo } from "react";
import { isUrlImage } from "../../../helpers/lemmy";
import useLemmyUrlHandler from "../../shared/useLemmyUrlHandler";
import { isUrlImage } from "../../../helpers/url";

const shared = css`
width: 30px;
11 changes: 10 additions & 1 deletion src/features/gallery/GalleryImg.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import React, { FocusEvent, KeyboardEvent, useContext, useRef } from "react";
import React, {
FocusEvent,
KeyboardEvent,
ReactEventHandler,
useContext,
useRef,
} from "react";
import "photoswipe/dist/photoswipe.css";
import { PostView } from "lemmy-js-client";
import { GalleryContext } from "./GalleryProvider";
@@ -10,6 +16,7 @@ export interface GalleryImgProps {
className?: string;
post?: PostView;
animationType?: PreparedPhotoSwipeOptions["showHideAnimationType"];
onError?: ReactEventHandler<HTMLImageElement> | undefined;
}

/**
@@ -28,6 +35,7 @@ export function GalleryImg({
className,
post,
animationType,
onError,
}: GalleryImgProps) {
const loaded = useRef(false);
const imgRef = useRef<HTMLImageElement>(null);
@@ -53,6 +61,7 @@ export function GalleryImg({

loaded.current = true;
}}
onError={onError}
/>
);
}
14 changes: 10 additions & 4 deletions src/features/gallery/PostGalleryImg.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import { PostView } from "lemmy-js-client";
import { isUrlImage } from "../../helpers/lemmy";
import { findLoneImage } from "../../helpers/markdown";
import { GalleryImg, GalleryImgProps } from "./GalleryImg";
import { isUrlImage } from "../../helpers/url";

export interface PostGalleryImgProps extends Omit<GalleryImgProps, "src"> {
post: PostView;
thumbnail?: boolean;
}

export default function PostGalleryImg({
post,
thumbnail = false,
...props
}: PostGalleryImgProps) {
return <GalleryImg {...props} src={getPostImage(post)} post={post} />;
return (
<GalleryImg {...props} src={getPostImage(post, thumbnail)} post={post} />
);
}

function getPostImage(post: PostView): string | undefined {
if (post.post.thumbnail_url) return post.post.thumbnail_url;
function getPostImage(post: PostView, thumbnail: boolean): string | undefined {
if (thumbnail && post.post.thumbnail_url) {
return post.post.thumbnail_url;
}

if (post.post.url && isUrlImage(post.post.url)) return post.post.url;

51 changes: 16 additions & 35 deletions src/features/post/detail/PostDetail.tsx
Original file line number Diff line number Diff line change
@@ -16,25 +16,23 @@ import {
} from "react";
import { findLoneImage } from "../../../helpers/markdown";
import { setPostRead } from "../postSlice";
import { isUrlImage, isUrlVideo } from "../../../helpers/lemmy";
import { maxWidthCss } from "../../shared/AppContent";
import PersonLink from "../../labels/links/PersonLink";
import { CommentSortType, PostView } from "lemmy-js-client";
import ViewAllComments from "./ViewAllComments";
import InlineMarkdown from "../../shared/InlineMarkdown";
import { megaphone } from "ionicons/icons";
import CommunityLink from "../../labels/links/CommunityLink";
import Video from "../../shared/Video";
import { css } from "@emotion/react";
import Nsfw, { isNsfw } from "../../labels/Nsfw";
import { PageContext } from "../../auth/PageContext";
import PostGalleryImg from "../../gallery/PostGalleryImg";
import { scrollIntoView } from "../../../helpers/dom";
import JumpFab from "../../comment/JumpFab";
import { OTapToCollapseType } from "../../../services/db";
import Locked from "./Locked";
import useAppToast from "../../../helpers/useAppToast";
import { postLocked } from "../../../helpers/toastMessages";
import Media from "../../shared/Media";
import { isUrlMedia } from "../../../helpers/url";

const BorderlessIonItem = styled(IonItem)`
--padding-start: 0;
@@ -57,19 +55,6 @@ const Container = styled.div`
width: 100%;
`;

const lightboxCss = css`
width: 100%;
max-height: 50vh;
object-fit: contain;
background: var(--lightroom-bg);
`;

const LightboxImg = styled(PostGalleryImg)`
-webkit-touch-callout: default;
${lightboxCss}
`;

const StyledMarkdown = styled(Markdown)`
margin: 16px 0;
@@ -127,6 +112,7 @@ export default function PostDetail({
sort,
}: PostDetailProps) {
const [collapsed, setCollapsed] = useState(false);
const [hasMediaError, setHasMediaError] = useState(false);
const dispatch = useAppDispatch();
const markdownLoneImage = useMemo(
() => (post?.post.body ? findLoneImage(post.post.body) : undefined),
@@ -170,37 +156,23 @@ export default function PostDetail({
[],
);

function renderImage() {
if (!post) return;

if (post.post.url) {
if (isUrlImage(post.post.url)) return <LightboxImg post={post} />;

if (isUrlVideo(post.post.url))
return <Video src={post.post.url} css={lightboxCss} controls />;
}

if (markdownLoneImage) return <LightboxImg post={post} />;
}

function renderText() {
if (!post) return;

if (post.post.body && !markdownLoneImage) {
return (
<>
{post.post.url &&
!isUrlImage(post.post.url) &&
!isUrlVideo(post.post.url) && <Embed post={post} />}
(!isUrlMedia(post.post.embed_video_url || post.post.url) ||
hasMediaError) && <Embed post={post} />}
<StyledMarkdown>{post.post.body}</StyledMarkdown>
</>
);
}

if (
post.post.url &&
!isUrlImage(post.post.url) &&
!isUrlVideo(post.post.url)
(!isUrlMedia(post.post.embed_video_url || post.post.url) || hasMediaError)
) {
return <StyledEmbed post={post} />;
}
@@ -224,7 +196,16 @@ export default function PostDetail({
}}
>
<Container>
<div onClick={(e) => e.stopPropagation()}>{renderImage()}</div>
{!hasMediaError && (
<div onClick={(e) => e.stopPropagation()}>
<Media
post={post}
detail={true}
blur={false}
onError={() => setHasMediaError(true)}
/>
</div>
)}
<PostDeets>
<Title ref={titleRef}>
<InlineMarkdown>{post.post.name}</InlineMarkdown>{" "}
4 changes: 2 additions & 2 deletions src/features/post/inFeed/compact/Thumbnail.tsx
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@ import { IonIcon } from "@ionic/react";
import { link, linkOutline } from "ionicons/icons";
import { PostView } from "lemmy-js-client";
import { MouseEvent, useCallback, useMemo } from "react";
import { isUrlImage } from "../../../../helpers/lemmy";
import { findLoneImage } from "../../../../helpers/markdown";
import { useAppDispatch, useAppSelector } from "../../../../store";
import PostGalleryImg from "../../../gallery/PostGalleryImg";
@@ -17,6 +16,7 @@ import {
OCompactThumbnailSizeType,
} from "../../../../services/db";
import { setPostRead } from "../../postSlice";
import { isUrlImage } from "../../../../helpers/url";

function getWidthForSize(size: CompactThumbnailSizeType): number {
switch (size) {
@@ -162,7 +162,7 @@ export default function Thumbnail({ post }: ImgProps) {
}

if (postImageSrc) {
return <StyledPostGallery post={post} blur={nsfw} />;
return <StyledPostGallery post={post} blur={nsfw} thumbnail />;
}

return <SelfSvg />;
68 changes: 14 additions & 54 deletions src/features/post/inFeed/large/LargePost.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { useState } from "react";
import styled from "@emotion/styled";
import { css } from "@emotion/react";
import { megaphone } from "ionicons/icons";
import PreviewStats from "../PreviewStats";
import Embed from "../../shared/Embed";
import { useMemo } from "react";
import { findLoneImage } from "../../../../helpers/markdown";
import { isUrlImage, isUrlVideo } from "../../../../helpers/lemmy";
import { maxWidthCss } from "../../../shared/AppContent";
import Nsfw, { isNsfw, isNsfwBlurred } from "../../../labels/Nsfw";
import { VoteButton } from "../../shared/VoteButton";
@@ -14,11 +12,11 @@ import PersonLink from "../../../labels/links/PersonLink";
import InlineMarkdown from "../../../shared/InlineMarkdown";
import { AnnouncementIcon } from "../../../../pages/posts/PostPage";
import CommunityLink from "../../../labels/links/CommunityLink";
import Video from "../../../shared/Video";
import { PostProps } from "../Post";
import Save from "../../../labels/Save";
import { Image } from "./Image";
import { useAppSelector } from "../../../../store";
import Media from "../../../shared/Media";
import { isUrlMedia } from "../../../../helpers/url";

const Container = styled.div`
display: flex;
@@ -98,63 +96,16 @@ const PostBody = styled.div<{ isRead: boolean }>`
overflow: hidden;
`;

const ImageContainer = styled.div`
overflow: hidden;
margin: 0 -0.75rem;
`;

export default function LargePost({ post, communityMode }: PostProps) {
const hasBeenRead: boolean =
useAppSelector((state) => state.post.postReadById[post.post.id]) ||
post.read;
const markdownLoneImage = useMemo(
() => (post.post.body ? findLoneImage(post.post.body) : undefined),
[post],
);
const blurNsfw = useAppSelector(
(state) => state.settings.appearance.posts.blurNsfw,
);
const [hasMediaError, setHasMediaError] = useState(false);

function renderPostBody() {
if (post.post.url) {
if (isUrlImage(post.post.url)) {
return (
<ImageContainer>
<Image
blur={isNsfwBlurred(post, blurNsfw)}
post={post}
animationType="zoom"
/>
</ImageContainer>
);
}
if (isUrlVideo(post.post.url)) {
return (
<ImageContainer>
<Video src={post.post.url} blur={isNsfwBlurred(post, blurNsfw)} />
</ImageContainer>
);
}
}

if (markdownLoneImage)
return (
<ImageContainer>
<Image
blur={isNsfwBlurred(post, blurNsfw)}
post={post}
animationType="zoom"
/>
</ImageContainer>
);

/**
* Embedded video, image with a thumbanil
*/
if (post.post.thumbnail_url && post.post.url) {
return <Embed post={post} />;
}

/**
* text image with captions
*/
@@ -182,7 +133,16 @@ export default function LargePost({ post, communityMode }: PostProps) {
{isNsfw(post) && <Nsfw />}
</Title>

{renderPostBody()}
{!hasMediaError &&
isUrlMedia(post.post.embed_video_url || post.post.url || "") ? (
<Media
post={post}
blur={isNsfwBlurred(post, blurNsfw)}
onError={() => setHasMediaError(true)}
/>
) : (
renderPostBody()
)}

<Details>
<LeftDetails isRead={hasBeenRead}>
4 changes: 2 additions & 2 deletions src/features/post/new/PostEditorRoot.tsx
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ import { Centered, Spinner } from "../../auth/Login";
import { jwtSelector, urlSelector } from "../../auth/authSlice";
import { startCase } from "lodash";
import { css } from "@emotion/react";
import { getHandle, getRemoteHandle, isUrlImage } from "../../../helpers/lemmy";
import { getHandle, getRemoteHandle } from "../../../helpers/lemmy";
import { cameraOutline } from "ionicons/icons";
import { PostEditorProps } from "./PostEditor";
import NewPostText from "./NewPostText";
@@ -34,7 +34,7 @@ import PhotoPreview from "./PhotoPreview";
import { uploadImage } from "../../../services/lemmy";
import { receivedPosts } from "../postSlice";
import useAppToast from "../../../helpers/useAppToast";
import { isValidUrl } from "../../../helpers/url";
import { isValidUrl, isUrlImage } from "../../../helpers/url";
import { problemFetchingTitle } from "../../../helpers/toastMessages";

const Container = styled.div`
89 changes: 89 additions & 0 deletions src/features/shared/Media.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Video from "./Video";
import { useMemo, useState } from "react";
import { findLoneImage } from "../../helpers/markdown";
import { PostView } from "lemmy-js-client";
import {
isUrlImage,
isUrlMedia,
isUrlVideo,
transformUrl,
} from "../../helpers/url";
import { Image } from "../post/inFeed/large/Image";

interface MediaProps {
post: PostView;
onError: () => void;
detail?: boolean;
blur?: boolean;
}

const Media = ({ post, detail = false, blur = true, onError }: MediaProps) => {
const postUrl = transformUrl(post.post.url || "");
const embedVideoUrl = transformUrl(post.post.embed_video_url || "");
const thumbnailUrl = transformUrl(post.post.thumbnail_url || "");
const [url, setUrl] = useState(embedVideoUrl || postUrl);
const isImage = isUrlImage(url as string);
const isVideo = isUrlVideo(url as string);

const markdownLoneImage = useMemo(
() => (post?.post.body ? findLoneImage(post.post.body) : undefined),
[post],
);

const handleMediaError = () => {
// Cycle the video url before throwing an error
if (isVideo && url === embedVideoUrl) {
if (isUrlMedia(postUrl)) {
return setUrl(postUrl);
}
}

// Cycle the image url before throwing an error
if (isImage && url === postUrl) {
return setUrl(thumbnailUrl);
}

onError();
};

if (!url) {
return;
}

// Change the url as we need
const postWithUrl = { ...post, post: { ...post.post, url: url } };

if (postUrl && isUrlImage(postUrl)) {
return (
<Image
blur={blur}
post={postWithUrl}
animationType="zoom"
onError={handleMediaError}
/>
);
}

if (isVideo) {
return (
<Video
src={url}
blur={blur}
controls={detail}
onError={handleMediaError}
/>
);
}

if (markdownLoneImage)
return (
<Image
blur={blur}
post={postWithUrl}
animationType="zoom"
onError={handleMediaError}
/>
);
};

export default Media;
19 changes: 17 additions & 2 deletions src/features/shared/Video.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import { Dictionary } from "@reduxjs/toolkit";
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import {
ChangeEvent,
useCallback,
useEffect,
useRef,
useState,
ReactEventHandler,
} from "react";
import { useInView } from "react-intersection-observer";
import { isAppleDeviceInstallable } from "../../helpers/device";

@@ -73,11 +80,18 @@ interface VideoProps {
blur?: boolean;

className?: string;
onError?: ReactEventHandler<HTMLVideoElement> | undefined;
}

const videoPlaybackPlace: Dictionary<number> = {};

export default function Video({ src, controls, blur, className }: VideoProps) {
export default function Video({
src,
controls,
blur,
className,
onError,
}: VideoProps) {
const [inViewRef, inView] = useInView({
threshold: 0.5,
});
@@ -134,6 +148,7 @@ export default function Video({ src, controls, blur, className }: VideoProps) {
playsInline
autoPlay={false}
controls={controls}
onError={onError}
onTimeUpdate={(e: ChangeEvent<HTMLVideoElement>) => {
setProgress(e.target.currentTime / e.target.duration);
}}
32 changes: 0 additions & 32 deletions src/helpers/lemmy.ts
Original file line number Diff line number Diff line change
@@ -230,38 +230,6 @@ export function getFlattenedChildren(comment: CommentNodeI): CommentView[] {
return flattenedChildren;
}

export function isUrlImage(url: string): boolean {
let parsedUrl;

try {
parsedUrl = new URL(url);
} catch (error) {
console.error(error);
return false;
}

return (
parsedUrl.pathname.endsWith(".jpeg") ||
parsedUrl.pathname.endsWith(".png") ||
parsedUrl.pathname.endsWith(".gif") ||
parsedUrl.pathname.endsWith(".jpg") ||
parsedUrl.pathname.endsWith(".webp")
);
}

export function isUrlVideo(url: string): boolean {
let parsedUrl;

try {
parsedUrl = new URL(url);
} catch (error) {
console.error(error);
return false;
}

return parsedUrl.pathname.endsWith(".mp4");
}

export function share(item: Post | Comment) {
return Share.share({ url: item.ap_id });
}
61 changes: 61 additions & 0 deletions src/helpers/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
isValidUrl,
transformUrl,
isUrlImage,
isUrlVideo,
isUrlMedia,
} from "./url";

describe("URL Utility Functions", () => {
describe("isValidUrl", () => {
it("returns true for a valid URL", () => {
expect(isValidUrl("https://www.example.com")).toBe(true);
});
});

describe("transformUrl", () => {
it("transform gifv to mp4", () => {
const inputUrl = "https://example.com/video.gifv";
const transformedUrl = transformUrl(inputUrl);
expect(transformedUrl).toBe("https://example.com/video.mp4");
});

it("handle unknown URLs", () => {
const inputUrl = "https://example.com/unknown-url";
const transformedUrl = transformUrl(inputUrl);
expect(transformedUrl).toBe(inputUrl);
});
});

describe("isUrlImage", () => {
it("returns true for image URLs", () => {
expect(isUrlImage("https://example.com/image.jpg")).toBe(true);
});

it("returns false for non-image URLs", () => {
expect(isUrlImage("https://example.com/video.mp4")).toBe(false);
});
});

describe("isUrlVideo", () => {
it("returns true for mp4", () => {
expect(isUrlVideo("https://example.com/video.mp4")).toBe(true);
});

it("returns true for mov", () => {
expect(isUrlVideo("https://example.com/video.mov")).toBe(true);
});

it("returns false for non-video URLs", () => {
expect(isUrlVideo("https://example.com/image.jpg")).toBe(false);
});
});

describe("isUrlMedia", () => {
it("returns true for media URLs", () => {
expect(isUrlMedia("https://example.com/image.jpg")).toBe(true);
expect(isUrlMedia("https://example.com/video.mp4")).toBe(true);
expect(isUrlMedia("https://example.com/video.gifv")).toBe(true);
});
});
});
54 changes: 54 additions & 0 deletions src/helpers/url.ts
Original file line number Diff line number Diff line change
@@ -17,3 +17,57 @@ export function isValidUrl(

return url.protocol === "http:" || url.protocol === "https:";
}

// Transform known video platform urls into their embed counterparts
export const transformUrl = (inputUrl: string): string => {
// If the URL contains gifv replace it with mp4 in order for the video to play inline
const url = inputUrl.replace(/\.gifv/g, ".mp4");
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this fixing a particular site or set of sites? I would like to avoid changing any instance of .gifv in the url, that feels quite dangerous.


return url;
};

const parseUrl = (url: string): URL | null => {
try {
return new URL(url);
} catch (error) {
console.error(error);
return null;
}
};

export function isUrlImage(url: string): boolean {
const parsedUrl = parseUrl(url);

if (!parsedUrl) {
return false;
}

return (
parsedUrl.pathname.endsWith(".jpeg") ||
parsedUrl.pathname.endsWith(".png") ||
parsedUrl.pathname.endsWith(".gif") ||
parsedUrl.pathname.endsWith(".jpg") ||
parsedUrl.pathname.endsWith(".webp")
);
}

export const isUrlVideo = (url: string): boolean => {
const parsedUrl = parseUrl(url);

if (parsedUrl) {
// Check if the URL contains ".mp4" in its pathname
url = parsedUrl.pathname.toLowerCase();
return url.includes(".mp4") || url.includes(".mov");
}

return false;
};

export const isUrlMedia = (url: string): boolean => {
if (!url || url === "") {
return false;
}

const transformedUrl = transformUrl(url);
return isUrlImage(transformedUrl) || isUrlVideo(transformedUrl);
};
2 changes: 1 addition & 1 deletion src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
import "@testing-library/jest-dom/vitest";

// Mock matchmedia
window.matchMedia =