Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/add_support_for_youtube_embeds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

# Add support for youtube embeds.
18 changes: 13 additions & 5 deletions src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
UnsupportedContent,
VideoContent,
} from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { UrlPreviewCard, UrlPreviewHolder, ClientPreview } from './url-preview';
import { Image, MediaControl, PersistedVolumeVideo } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
Expand All @@ -43,6 +43,7 @@ type RenderMessageContentProps = {
getContent: <T>() => T;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
clientUrlPreview?: boolean;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
Expand All @@ -68,6 +69,7 @@ function RenderMessageContentInternal({
getContent,
mediaAutoLoad,
urlPreview,
clientUrlPreview,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
Expand Down Expand Up @@ -112,13 +114,19 @@ function RenderMessageContentInternal({

return (
<UrlPreviewHolder>
{toRender.map(({ url, type }) => (
<UrlPreviewCard key={url} url={url} ts={ts} mediaType={type} />
))}
{toRender.map(({ url, type }) => {
if (type) {
return <UrlPreviewCard key={url} url={url} ts={ts} mediaType={type} />;
}
if (clientUrlPreview) {
return <ClientPreview url={url} />;
}
return null;
})}
</UrlPreviewHolder>
);
},
[ts]
[ts, clientUrlPreview]
);

const renderCaption = () => {
Expand Down
206 changes: 206 additions & 0 deletions src/app/components/url-preview/ClientPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useState, ReactNode } from 'react';
import { Box, Badge, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { encodeBlurHash } from '$utils/blurHash';
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '$types/matrix/common';
import { Attachment, AttachmentBox, AttachmentHeader } from '../message/attachment';
import { Image } from '../media';
import { UrlPreview } from './UrlPreview';
import { VideoContent } from '../message';

interface OEmbed {
type: 'photo' | 'video' | 'link' | 'rich';
version: '1.0';
title?: string;
author_name?: string;
author_url?: string;
provider_name?: string;
provider_url?: string;
cache_age?: string;
thumbnail_url?: string;
thumbnail_width?: number;
thumbnail_height?: number;
url?: string;
html?: string;
width?: number;
height?: number;
}

async function oEmbedData(url: string): Promise<OEmbed> {
const data = await fetch(url).then((resp) => resp.json());

return data;
}

export type EmbedHeaderProps = {
title: string;
source: string;
after?: ReactNode;
};
export const EmbedHeader = as<'div', EmbedHeaderProps>(({ title, source, after }) => (
<AttachmentHeader>
<Box alignItems="Center" gap="200" grow="Yes">
<Box shrink="No">
<Badge style={{ maxWidth: toRem(100) }} variant="Secondary" radii="Pill">
<Text size="O400" truncate>
{source}
</Text>
</Badge>
</Box>
<Box grow="Yes">
<Text size="T300" truncate>
{title}
</Text>
</Box>
{after}
</Box>
</AttachmentHeader>
));

type EmbedOpenButtonProps = {
url: string;
};
export function EmbedOpenButton({ url }: EmbedOpenButtonProps) {
return (
<IconButton size="300" radii="300" onClick={() => window.open(url, '_blank')}>
<Icon size="100" src={Icons.Link} />
</IconButton>
);
}

type YoutubeElementProps = {
videoId: string;
embedData: OEmbed;
};

export const YoutubeElement = as<'div', YoutubeElementProps>(({ videoId, embedData }) => {
const thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
const iframeSrc = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}?autoplay=1`;
const videoUrl = `https://youtube.com/watch?v=${videoId}`;

const [blurHash, setBlurHash] = useState<string | undefined>();

const title = embedData.title ? embedData.title : '';

return (
<Attachment
style={{
flexGrow: 1,
flexShrink: 0,
width: '640px',
height: '400px',
}}
>
<AttachmentHeader>
<EmbedHeader title={title} source="YOUTUBE" after={EmbedOpenButton({ url: videoUrl })} />
</AttachmentHeader>
<AttachmentBox
style={{
height: '100%',
width: '100%',
}}
>
<VideoContent
body={title}
mimeType="fake"
url={videoUrl}
info={{
thumbnail_info: { [MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash },
}}
renderThumbnail={() => (
<Image
src={thumbnailUrl}
/*
this allows the blurhash to be computed, otherwise it throws an "insecure operation" error
maybe that happens for a good reason, in which case this should probably be removed
*/
crossOrigin="anonymous"
onLoad={(e) => {
setBlurHash(encodeBlurHash(e.currentTarget, 32, 32));
}}
/>
)}
renderVideo={({ onLoadedMetadata }) => (
<iframe
src={iframeSrc}
title="YouTube embed"
onLoad={onLoadedMetadata}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
width="640"
height="360"
allowFullScreen
/>
)}
/>
</AttachmentBox>
</Attachment>
);
});

const youtubeUrl = (url: string) => url.match(/(https:\/\/)(www\.|m\.|)(youtube\.com|youtu\.be)\//);

export const ClientPreview = as<'div', { url: string }>(({ url, ...props }, ref) => {
const [showYoutube] = useSetting(settingsAtom, 'clientPreviewYoutube');

// this component is overly complicated, because it was designed to support more embed types than just youtube
// i'm leaving this mess here to support later expansion
const isYoutube = !!youtubeUrl(url);
const videoId = isYoutube ? url.match(/(?:shorts\/|watch\?v=|youtu\.be\/)(.{11})/)?.[1] : null;

const fetchUrl =
isYoutube && videoId
? `https://www.youtube.com/oembed?url=${encodeURIComponent(`https://youtube.com/watch?v=${videoId}`)}`
: url;

const [embedStatus, loadEmbed] = useAsyncCallback(
useCallback(() => oEmbedData(fetchUrl), [fetchUrl])
);

useEffect(() => {
const fetchYoutube = isYoutube && showYoutube;

if (fetchYoutube) loadEmbed();
}, [isYoutube, showYoutube, loadEmbed]);

let previewContent;

if (isYoutube && videoId) {
if (showYoutube) {
if (embedStatus.status === AsyncStatus.Error) return null;

if (embedStatus.status === AsyncStatus.Success && embedStatus.data) {
previewContent = <YoutubeElement videoId={videoId} embedData={embedStatus.data} />;
} else {
previewContent = (
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
);
}
}
}

return (
<UrlPreview
{...props}
ref={ref}
style={{
background: 'transparent',
border: 'none',
padding: 0,
boxShadow: 'none',
display: 'inline-block',
verticalAlign: 'middle',
width: 'max-content',
minWidth: 0,
maxWidth: '100%',
margin: 0,
}}
>
{previewContent}
</UrlPreview>
);
});
1 change: 1 addition & 0 deletions src/app/components/url-preview/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './UrlPreview';
export * from './UrlPreviewCard';
export * from './ClientPreview';
6 changes: 6 additions & 0 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ export function RoomTimeline({
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [clientUrlPreview] = useSetting(settingsAtom, 'clientUrlPreview');
const [encClientUrlPreview] = useSetting(settingsAtom, 'encClientUrlPreview');
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
Expand All @@ -142,6 +144,9 @@ export function RoomTimeline({
const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly');

const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const showClientUrlPreview = room.hasEncryptionStateEvent()
? encClientUrlPreview
: clientUrlPreview;

const nicknames = useAtomValue(nicknamesAtom);
const globalProfiles = useAtomValue(profilesCacheAtom);
Expand Down Expand Up @@ -483,6 +488,7 @@ export function RoomTimeline({
dateFormatString,
mediaAutoLoad,
showUrlPreview,
showClientUrlPreview,
autoplayStickers,
hideMemberInReadOnly,
isReadOnly,
Expand Down
67 changes: 67 additions & 0 deletions src/app/features/settings/general/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,15 @@ function Messages() {
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [clientUrlPreview, setClientUrlPreview] = useSetting(settingsAtom, 'clientUrlPreview');
const [encClientUrlPreview, setEncClientUrlPreview] = useSetting(
settingsAtom,
'encClientUrlPreview'
);
const [clientPreviewYoutube, setClientPreviewYoutube] = useSetting(
settingsAtom,
'clientPreviewYoutube'
);
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showTombstoneEvents, setShowTombstoneEvents] = useSetting(
settingsAtom,
Expand Down Expand Up @@ -957,6 +966,64 @@ function Messages() {
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Client Side Embeds"
description="Attempt to preview unsupported urls (e.g. YouTube) on the client, without involving the homeserver. This will expose your IP Address to third party services."
after={
<Switch
variant="Primary"
value={clientUrlPreview}
onChange={setClientUrlPreview}
title={clientUrlPreview ? 'Disable client-side embeds' : 'Enable client-side embeds'}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
style={clientUrlPreview ? {} : { display: 'none' }}
>
<SettingTile
title="Client Embeds in Encrypted Rooms"
after={
<Switch
variant="Primary"
value={encClientUrlPreview}
onChange={setEncClientUrlPreview}
title={
encClientUrlPreview
? 'Disable client-side embeds in encrypted rooms'
: 'Enable client-side embeds in encrypted rooms'
}
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
style={clientUrlPreview ? {} : { display: 'none' }}
>
<SettingTile
title="Embed YouTube Links"
after={
<Switch
variant="Primary"
value={clientPreviewYoutube}
onChange={setClientPreviewYoutube}
title={
clientPreviewYoutube
? 'Disable client-side Youtube video embeds'
: 'Enable client-side Youtube video embeds'
}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Member Events in Read-Only Rooms"
Expand Down
Loading
Loading