forked from github/cinny
🦉 video preview support
This commit is contained in:
@@ -26,7 +26,10 @@ import {
|
||||
} from './message';
|
||||
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
|
||||
import { ExternalImageCard } from '../../owl/components/ExternalImageCard';
|
||||
import { ExternalVideoCard } from '../../owl/components/ExternalVideoCard';
|
||||
import { YouTubeEmbedCard } from '../../owl/components/YouTubeEmbedCard';
|
||||
import { isImageUrl } from '../../owl/utils/imageUrl';
|
||||
import { getYouTubeId, isVideoUrl } from '../../owl/utils/videoUrl';
|
||||
import { Image, MediaControl, Video } from './media';
|
||||
import { ImageViewer } from './image-viewer';
|
||||
import { PdfViewer } from './Pdf-viewer';
|
||||
@@ -42,6 +45,7 @@ type RenderMessageContentProps = {
|
||||
getContent: <T>() => T;
|
||||
mediaAutoLoad?: boolean;
|
||||
urlPreview?: boolean;
|
||||
allowExternalVideos?: boolean;
|
||||
highlightRegex?: RegExp;
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
@@ -55,6 +59,7 @@ export function RenderMessageContent({
|
||||
getContent,
|
||||
mediaAutoLoad,
|
||||
urlPreview,
|
||||
allowExternalVideos,
|
||||
highlightRegex,
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
@@ -65,13 +70,28 @@ export function RenderMessageContent({
|
||||
if (filteredUrls.length === 0) return undefined;
|
||||
|
||||
const imageUrls = filteredUrls.filter(isImageUrl);
|
||||
const otherUrls = filteredUrls.filter((url) => !isImageUrl(url));
|
||||
const nonImage = filteredUrls.filter((url) => !isImageUrl(url));
|
||||
const youtubeUrls = allowExternalVideos
|
||||
? nonImage.filter((url) => getYouTubeId(url) !== null)
|
||||
: [];
|
||||
const videoUrls = allowExternalVideos
|
||||
? nonImage.filter((url) => getYouTubeId(url) === null && isVideoUrl(url))
|
||||
: [];
|
||||
const otherUrls = nonImage.filter(
|
||||
(url) => !youtubeUrls.includes(url) && !videoUrls.includes(url)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{imageUrls.map((url) => (
|
||||
<ExternalImageCard key={url} url={url} />
|
||||
))}
|
||||
{youtubeUrls.map((url) => (
|
||||
<YouTubeEmbedCard key={url} url={url} />
|
||||
))}
|
||||
{videoUrls.map((url) => (
|
||||
<ExternalVideoCard key={url} url={url} />
|
||||
))}
|
||||
{otherUrls.length > 0 && (
|
||||
<UrlPreviewHolder>
|
||||
{otherUrls.map((url) => (
|
||||
|
||||
@@ -117,6 +117,7 @@ import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages';
|
||||
import { useAllowExternalVideos } from '../../../owl/hooks/useAllowExternalVideos';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
@@ -449,6 +450,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const allowExternalImages = useAllowExternalImages(mx, room);
|
||||
const allowExternalVideos = useAllowExternalVideos(mx, room);
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
@@ -1107,6 +1109,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
allowExternalVideos={allowExternalVideos}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
@@ -1213,6 +1216,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
allowExternalVideos={allowExternalVideos}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
|
||||
@@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { DateFormat, ExternalImageMode, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { DateFormat, ExternalImageMode, ExternalVideoMode, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { KeySymbol } from '../../../utils/key-symbol';
|
||||
import { isMacOS } from '../../../utils/user-agent';
|
||||
@@ -953,6 +953,80 @@ function SelectExternalImages() {
|
||||
);
|
||||
}
|
||||
|
||||
const externalVideoItems: { mode: ExternalVideoMode; name: string }[] = [
|
||||
{ mode: 'never', name: 'Never' },
|
||||
{ mode: 'homeserver', name: 'Homeserver Only' },
|
||||
{ mode: 'always', name: 'Always' },
|
||||
];
|
||||
|
||||
function SelectExternalVideos() {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [externalVideos, setExternalVideos] = useSetting(settingsAtom, 'externalVideos');
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (mode: ExternalVideoMode) => {
|
||||
setExternalVideos(mode);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Text size="T300">
|
||||
{externalVideoItems.find((i) => i.mode === externalVideos)?.name ?? externalVideos}
|
||||
</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{externalVideoItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.mode}
|
||||
size="300"
|
||||
variant={externalVideos === item.mode ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(item.mode)}
|
||||
>
|
||||
<Text size="T300">{item.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Messages() {
|
||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||||
settingsAtom,
|
||||
@@ -1046,6 +1120,13 @@ function Messages() {
|
||||
after={<SelectExternalImages />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="External Videos"
|
||||
description="Embed YouTube links and play direct video URLs (mp4, webm) inline."
|
||||
after={<SelectExternalVideos />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Hidden Events"
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum MessageLayout {
|
||||
}
|
||||
|
||||
export type ExternalImageMode = 'always' | 'homeserver' | 'never';
|
||||
export type ExternalVideoMode = 'always' | 'homeserver' | 'never';
|
||||
|
||||
export interface Settings {
|
||||
themeId?: string;
|
||||
@@ -42,6 +43,7 @@ export interface Settings {
|
||||
showHiddenEvents: boolean;
|
||||
legacyUsernameColor: boolean;
|
||||
externalImages: ExternalImageMode;
|
||||
externalVideos: ExternalVideoMode;
|
||||
|
||||
showNotifications: boolean;
|
||||
isNotificationSounds: boolean;
|
||||
@@ -77,6 +79,7 @@ const defaultSettings: Settings = {
|
||||
showHiddenEvents: false,
|
||||
legacyUsernameColor: false,
|
||||
externalImages: 'never',
|
||||
externalVideos: 'never',
|
||||
|
||||
showNotifications: true,
|
||||
isNotificationSounds: true,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const ExternalVideoCard = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-block',
|
||||
maxWidth: toRem(480),
|
||||
borderRadius: config.radii.R300,
|
||||
overflow: 'hidden',
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ExternalVideo = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
maxHeight: toRem(400),
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, as, color } from 'folds';
|
||||
import * as css from './ExternalVideoCard.css';
|
||||
import { tryDecodeURIComponent } from '../../app/utils/dom';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
export const ExternalVideoCard = as<'div', { url: string }>(({ url, ...props }, ref) => {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
if (error) return null;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100" {...props} ref={ref}>
|
||||
<div className={css.ExternalVideoCard}>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
className={css.ExternalVideo}
|
||||
src={url}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
</div>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{tryDecodeURIComponent(url)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const YouTubeCard = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
maxWidth: toRem(480),
|
||||
borderRadius: config.radii.R300,
|
||||
overflow: 'hidden',
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubeFrame = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubeThumbButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: '#000',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
|
||||
':hover': {
|
||||
filter: 'brightness(1.05)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubeThumbImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubePlayOverlay = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: toRem(64),
|
||||
height: toRem(64),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubeIframe = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Icon, Icons, Text, as, color } from 'folds';
|
||||
import * as css from './YouTubeEmbedCard.css';
|
||||
import { tryDecodeURIComponent } from '../../app/utils/dom';
|
||||
import { getYouTubeId, getYouTubeStart } from '../utils/videoUrl';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
export const YouTubeEmbedCard = as<'div', { url: string }>(({ url, ...props }, ref) => {
|
||||
const id = getYouTubeId(url);
|
||||
const start = getYouTubeStart(url);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [thumbError, setThumbError] = useState(false);
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
const embedSrc =
|
||||
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}` +
|
||||
`?autoplay=1&rel=0${start ? `&start=${start}` : ''}`;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100" {...props} ref={ref}>
|
||||
<div className={css.YouTubeCard}>
|
||||
<div className={css.YouTubeFrame}>
|
||||
{playing ? (
|
||||
<iframe
|
||||
className={css.YouTubeIframe}
|
||||
src={embedSrc}
|
||||
title={`YouTube: ${id}`}
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={css.YouTubeThumbButton}
|
||||
onClick={() => setPlaying(true)}
|
||||
aria-label="Play YouTube video"
|
||||
>
|
||||
{!thumbError && (
|
||||
<img
|
||||
className={css.YouTubeThumbImg}
|
||||
src={`https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setThumbError(true)}
|
||||
/>
|
||||
)}
|
||||
<span className={css.YouTubePlayOverlay}>
|
||||
<Icon size="400" src={Icons.Play} filled />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{tryDecodeURIComponent(url)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import { useSetting } from '../../app/state/hooks/settings';
|
||||
import { settingsAtom } from '../../app/state/settings';
|
||||
|
||||
export function useAllowExternalVideos(mx: MatrixClient, room: Room): boolean {
|
||||
const [externalVideos] = useSetting(settingsAtom, 'externalVideos');
|
||||
return useMemo(() => {
|
||||
if (externalVideos === 'always') return true;
|
||||
if (externalVideos === 'never') return false;
|
||||
const roomDomain = room.roomId.split(':')[1];
|
||||
return roomDomain === mx.getDomain();
|
||||
}, [externalVideos, room.roomId, mx]);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.m4v'];
|
||||
|
||||
export function isVideoUrl(url: string): boolean {
|
||||
try {
|
||||
const { pathname } = new URL(url);
|
||||
return VIDEO_EXTENSIONS.some((ext) => pathname.toLowerCase().endsWith(ext));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getYouTubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname.replace(/^www\.|^m\./, '');
|
||||
if (host === 'youtu.be') {
|
||||
const id = u.pathname.slice(1).split('/')[0];
|
||||
return id || null;
|
||||
}
|
||||
if (host === 'youtube.com') {
|
||||
if (u.pathname === '/watch') return u.searchParams.get('v');
|
||||
const m = u.pathname.match(/^\/(shorts|embed|v|live)\/([^/]+)/);
|
||||
if (m) return m[2];
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getYouTubeStart(url: string): number | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const raw = u.searchParams.get('t') ?? u.searchParams.get('start');
|
||||
if (!raw) return null;
|
||||
if (/^\d+$/.test(raw)) return parseInt(raw, 10);
|
||||
const m = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/);
|
||||
if (!m) return null;
|
||||
const total = (parseInt(m[1] || '0', 10)) * 3600
|
||||
+ (parseInt(m[2] || '0', 10)) * 60
|
||||
+ (parseInt(m[3] || '0', 10));
|
||||
return total > 0 ? total : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user