Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
52 changes: 52 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,49 @@ 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} />
}
/>
</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}
/>
}
/>
</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}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Member Events in Read-Only Rooms"
Expand Down
4 changes: 4 additions & 0 deletions src/app/hooks/timeline/useTimelineEventRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export interface TimelineEventRendererOptions {
dateFormatString: string;
mediaAutoLoad: boolean;
showUrlPreview: boolean;
showClientUrlPreview: boolean;
autoplayStickers: boolean;
hideMemberInReadOnly: boolean;
isReadOnly: boolean;
Expand Down Expand Up @@ -250,6 +251,7 @@ export function useTimelineEventRenderer({
dateFormatString,
mediaAutoLoad,
showUrlPreview,
showClientUrlPreview,
autoplayStickers,
hideMemberInReadOnly,
isReadOnly,
Expand Down Expand Up @@ -427,6 +429,7 @@ export function useTimelineEventRenderer({
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
clientUrlPreview={showClientUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
Expand Down Expand Up @@ -589,6 +592,7 @@ export function useTimelineEventRenderer({
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
clientUrlPreview={showClientUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
Expand Down
Loading
Loading