Skip to content

feat: youtube embeds #4841

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 6 additions & 1 deletion web/src/components/MemoView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { memo, useCallback, useState } from "react";
import { memo, useCallback, useState, useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useAsyncEffect from "@/hooks/useAsyncEffect";
Expand All @@ -13,13 +13,15 @@ import { Memo, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_s
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityToString } from "@/utils/memo";
import { isSuperUser } from "@/utils/user";
import { extractYoutubeVideoIdsFromNodes } from "@/utils/youtube";
import MemoActionMenu from "./MemoActionMenu";
import MemoAttachmentListView from "./MemoAttachmentListView";
import MemoContent from "./MemoContent";
import MemoEditor from "./MemoEditor";
import MemoLocationView from "./MemoLocationView";
import MemoReactionistView from "./MemoReactionListView";
import MemoRelationListView from "./MemoRelationListView";
import MemoYoutubeEmbedListView from "./MemoYoutubeEmbedListView";
import PreviewImageDialog from "./PreviewImageDialog";
import ReactionSelector from "./ReactionSelector";
import UserAvatar from "./UserAvatar";
Expand Down Expand Up @@ -65,6 +67,8 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
workspaceMemoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => workspaceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));

const youtubeVideoIds = useMemo(() => extractYoutubeVideoIdsFromNodes(memo.nodes), [memo.nodes]);

// Initial related data: creator.
useAsyncEffect(async () => {
const user = await userStore.getOrFetchUserByName(memo.creator);
Expand Down Expand Up @@ -246,6 +250,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
parentPage={parentPage}
/>
{memo.location && <MemoLocationView location={memo.location} />}
<MemoYoutubeEmbedListView videoIds={youtubeVideoIds} />
<MemoAttachmentListView attachments={memo.attachments} />
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />
Expand Down
40 changes: 40 additions & 0 deletions web/src/components/MemoYoutubeEmbedListView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { memo } from "react";
import { cn } from "@/lib/utils";

interface Props {
videoIds: string[];
}

const MemoYoutubeEmbedListView: React.FC<Props> = ({ videoIds }: Props) => {
if (!videoIds || videoIds.length === 0) {
return null;
}

const EmbedCard = ({ videoId, className }: { videoId: string; className?: string }) => {
return (
<div className={cn("relative w-full", className)}>
<div className="relative w-full pt-[56.25%] rounded-lg overflow-hidden border border-border/60 bg-popover">
<iframe
className="absolute top-0 left-0 w-full h-full"
src={`https://www.youtube.com/embed/${videoId}`}
Copy link
Preview

Copilot AI Jul 17, 2025

Choose a reason for hiding this comment

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

The videoId is directly interpolated into the iframe src without validation or sanitization. Although the video IDs are extracted using regex patterns, consider adding additional validation to ensure the videoId contains only expected characters (alphanumeric, hyphens, underscores) to prevent potential XSS attacks.

Copilot uses AI. Check for mistakes.

title="YouTube video player"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
Copy link
Preview

Copilot AI Jul 17, 2025

Choose a reason for hiding this comment

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

[nitpick] The iframe permissions are quite permissive, including clipboard-write and accelerometer access. Consider restricting permissions to only what's necessary for video playback (e.g., encrypted-media, picture-in-picture) to follow the principle of least privilege.

Suggested change
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allow="encrypted-media; picture-in-picture"

Copilot uses AI. Check for mistakes.

allowFullScreen
/>
</div>
</div>
);
};

return (
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
{videoIds.map((id) => (
<div key={id} className="w-80 flex flex-col justify-start items-start shrink-0">
<EmbedCard videoId={id} className="max-h-64 grow" />
</div>
))}
</div>
);
};
Comment on lines +13 to +38
Copy link
Preview

Copilot AI Jul 17, 2025

Choose a reason for hiding this comment

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

The EmbedCard component is defined inside the render function, which means it will be recreated on every render. Consider moving this component outside of the main component or using useCallback to optimize performance.

Suggested change
const EmbedCard = ({ videoId, className }: { videoId: string; className?: string }) => {
return (
<div className={cn("relative w-full", className)}>
<div className="relative w-full pt-[56.25%] rounded-lg overflow-hidden border border-border/60 bg-popover">
<iframe
className="absolute top-0 left-0 w-full h-full"
src={`https://www.youtube.com/embed/${videoId}`}
title="YouTube video player"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
</div>
);
};
return (
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
{videoIds.map((id) => (
<div key={id} className="w-80 flex flex-col justify-start items-start shrink-0">
<EmbedCard videoId={id} className="max-h-64 grow" />
</div>
))}
</div>
);
};
const EmbedCard = memo(({ videoId, className }: { videoId: string; className?: string }) => {
return (
<div className={cn("relative w-full", className)}>
<div className="relative w-full pt-[56.25%] rounded-lg overflow-hidden border border-border/60 bg-popover">
<iframe
className="absolute top-0 left-0 w-full h-full"
src={`https://www.youtube.com/embed/${videoId}`}
title="YouTube video player"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>

Copilot uses AI. Check for mistakes.

Copy link
Author

Choose a reason for hiding this comment

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

I already commented on Copilots "suggestions" once.


export default memo(MemoYoutubeEmbedListView);
85 changes: 85 additions & 0 deletions web/src/utils/youtube.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Node, NodeType } from "@/types/proto/api/v1/markdown_service";

// Regular expressions to match various YouTube URL formats.
const YOUTUBE_REGEXPS: RegExp[] = [
/https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^&\s]+)/i,
/https?:\/\/(?:www\.)?youtu\.be\/([^?\s]+)/i,
/https?:\/\/(?:www\.)?youtube\.com\/shorts\/([^?\s]+)/i,
/https?:\/\/(?:www\.)?youtube\.com\/embed\/([^?\s]+)/i,
];

/**
* Extract the YouTube video ID from a given URL, if any.
* @param url The URL string to parse.
* @returns The video ID, or undefined if the URL is not a YouTube link.
*/
export const extractYoutubeIdFromUrl = (url: string): string | undefined => {
for (const regexp of YOUTUBE_REGEXPS) {
const match = url.match(regexp);
if (match?.[1]) {
return match[1];
}
}
return undefined;
};

/**
* Extract YouTube video IDs from markdown nodes.
* @param nodes The array of markdown nodes to extract YouTube video IDs from.
* @returns A deduplicated array of YouTube video IDs.
*/
export const extractYoutubeVideoIdsFromNodes = (nodes: Node[]): string[] => {
const ids = new Set<string>();

const isNodeArray = (value: unknown): value is Node[] =>
Array.isArray(value) &&
value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
"type" in (value[0] as Record<string, unknown>);

// Collect all child Node instances nested anywhere inside the given node
const collectChildren = (node: Node): Node[] => {
const collected: Node[] = [];
const queue: unknown[] = Object.values(node);

while (queue.length) {
const item = queue.shift();
if (!item) continue;

if (isNodeArray(item)) {
collected.push(...item);
continue;
}

if (Array.isArray(item)) {
queue.push(...item);
} else if (typeof item === "object") {
queue.push(...Object.values(item as Record<string, unknown>));
}
}

return collected;
};

const stack: Node[] = [...nodes];

while (stack.length) {
const node = stack.pop()!;
Copy link
Preview

Copilot AI Jul 17, 2025

Choose a reason for hiding this comment

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

Using the non-null assertion operator (!) on stack.pop() assumes the stack is never empty, but this could lead to runtime errors if the stack becomes empty unexpectedly. Consider using a safer approach like checking if the popped value exists before proceeding.

Suggested change
const node = stack.pop()!;
const node = stack.pop();
if (!node) continue;

Copilot uses AI. Check for mistakes.


if (node.type === NodeType.LINK && node.linkNode) {
const id = extractYoutubeIdFromUrl(node.linkNode.url);
if (id) ids.add(id);
} else if (node.type === NodeType.AUTO_LINK && node.autoLinkNode) {
const id = extractYoutubeIdFromUrl(node.autoLinkNode.url);
if (id) ids.add(id);
}

const children = collectChildren(node);
if (children.length) {
stack.push(...children);
}
}

return [...ids];
};