forked from github/cinny
🦉 video preview support
This commit is contained in:
@@ -18,8 +18,8 @@ In this owl folder should go all documents we produce for this Endeavour.
|
||||
- [x] Find out how we can set that Twitter Emojis are standard - system emojis on windows are terrible.
|
||||
- [x] Find out how we can set a theme by default for new users.
|
||||
- [x] img tags in messages should be rendered, without requiring them to be on a local cache. We do not need to secure against identification attacks.
|
||||
- [ ] youtube links should create embedded players (if enabled).
|
||||
- [ ] 9gag links with videos should allow them to be played if possible on demand.
|
||||
- [x] youtube links should create embedded players (if enabled).
|
||||
- [x] 9gag links with videos should allow them to be played if possible on demand.
|
||||
|
||||
|
||||
## Commit Messages
|
||||
|
||||
337
owl/REPORT-003-external-videos.md
Normal file
337
owl/REPORT-003-external-videos.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# OWL Report #003 - External Video Embeds (YouTube + Direct Video URLs)
|
||||
|
||||
Date: 2026-04-17
|
||||
|
||||
## The Goal
|
||||
|
||||
Two README items merge naturally into one feature:
|
||||
|
||||
- **YouTube links** should create embedded players (click-to-play).
|
||||
- **9gag-style direct video URLs** (e.g. `https://img-9gag-fun.9cache.com/photo/aYQnPXN_460svav1.mp4`) should be playable inline.
|
||||
|
||||
Both are "external video in the timeline". They share the same wiring points (URL detection → card renderer in `RenderMessageContent.tsx`) and the same policy model (`'always' | 'homeserver' | 'never'`, mirroring the external-images setting). Different rendering tech: YouTube needs an iframe, direct videos use the `<video>` tag — but that's an internal detail of the renderer.
|
||||
|
||||
---
|
||||
|
||||
## Existing Plumbing We Will Reuse
|
||||
|
||||
The external-images work (Report #002, now shipped) gave us the exact pattern to follow.
|
||||
|
||||
| Piece | File | Role |
|
||||
|-------|------|------|
|
||||
| URL extraction | `src/app/utils/regex.ts` → `URL_REG` / `HTTP_URL_PATTERN` | Already pulls every http(s) URL out of message text |
|
||||
| URL classifier | `src/owl/utils/imageUrl.ts` → `isImageUrl()` | Pattern we will mirror for `isVideoUrl`/`isYouTubeUrl` |
|
||||
| Render dispatch | `src/app/components/RenderMessageContent.tsx:63-84` → `renderUrlsPreview` | The single place we branch per URL kind |
|
||||
| Card component | `src/owl/components/ExternalImageCard.tsx` | Exact shape to mirror |
|
||||
| Policy setting | `src/app/state/settings.ts:44` → `externalImages: ExternalImageMode` | Copy for `externalVideos` |
|
||||
| Policy hook | `src/owl/hooks/useAllowExternalImages.ts` | Copy for `useAllowExternalVideos` |
|
||||
| Settings UI | `src/app/features/settings/general/General.tsx:882-954` (`SelectExternalImages`) + mount at line 1043-1048 | Copy for `SelectExternalVideos` |
|
||||
| Matrix `<video>` element | `src/app/components/media/Video.tsx` | Already exists — reusable for direct video rendering |
|
||||
|
||||
The key insight: `renderUrlsPreview` in `RenderMessageContent.tsx` is already where all URL-derived embeds dispatch. Today it routes images to `ExternalImageCard` and everything else to `UrlPreviewCard`. Adding video is one more branch in the same `filter()` chain. **No changes to `sanitize.ts` or the HTML parser are needed** — we are rendering from the URL list, not from user HTML.
|
||||
|
||||
---
|
||||
|
||||
## Part A — YouTube Embeds
|
||||
|
||||
### Detection
|
||||
|
||||
YouTube URL variants we want to catch:
|
||||
|
||||
```
|
||||
https://www.youtube.com/watch?v=VIDEOID
|
||||
https://youtube.com/watch?v=VIDEOID&t=42s
|
||||
https://youtu.be/VIDEOID
|
||||
https://youtu.be/VIDEOID?t=42
|
||||
https://www.youtube.com/shorts/VIDEOID
|
||||
https://www.youtube.com/embed/VIDEOID
|
||||
https://m.youtube.com/watch?v=VIDEOID
|
||||
```
|
||||
|
||||
Parse via `URL` rather than a mega-regex. That way a typo in the query string won't break detection:
|
||||
|
||||
```ts
|
||||
// src/owl/utils/videoUrl.ts
|
||||
export function getYouTubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname.replace(/^www\.|^m\./, '');
|
||||
if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null;
|
||||
if (host === 'youtube.com') {
|
||||
if (u.pathname === '/watch') return u.searchParams.get('v');
|
||||
const m = u.pathname.match(/^\/(shorts|embed|v)\/([^/]+)/);
|
||||
if (m) return m[2];
|
||||
}
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function getYouTubeStart(url: string): number | null {
|
||||
try {
|
||||
const t = new URL(url).searchParams.get('t') ?? new URL(url).searchParams.get('start');
|
||||
if (!t) return null;
|
||||
// handle "42", "42s", "1m30s"
|
||||
const m = t.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?/);
|
||||
if (!m) return null;
|
||||
return (+(m[1] || 0)) * 3600 + (+(m[2] || 0)) * 60 + (+(m[3] || 0));
|
||||
} catch { return null; }
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Strategy: "Facade" (Click-to-Load)
|
||||
|
||||
Don't render the iframe eagerly. Two reasons:
|
||||
|
||||
1. **Privacy**: YouTube's iframe (even on `youtube-nocookie.com`) phones home as soon as it mounts. Loading a full iframe for every YouTube link in a room scrollback is a tracking and performance problem.
|
||||
2. **Performance**: A YouTube iframe is ~500KB of JS per instance. Ten YouTube links in a channel would pin the tab.
|
||||
|
||||
Instead: show a clickable thumbnail from `https://i.ytimg.com/vi/<ID>/hqdefault.jpg` with a play-button overlay. On click, swap to the iframe. This is the standard approach (used by Reddit, Discord, Mastodon front-ends).
|
||||
|
||||
```
|
||||
┌───────────────────────────────────┐
|
||||
│ [ YouTube thumbnail image ] │
|
||||
│ ▶ (big play button) │
|
||||
│ │
|
||||
│ youtube.com/watch?v=dQw4w9WgXcQ │
|
||||
└───────────────────────────────────┘
|
||||
↓ click
|
||||
┌───────────────────────────────────┐
|
||||
│ [ <iframe> YouTube player ] │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
### iframe Attributes
|
||||
|
||||
When the user clicks to load:
|
||||
|
||||
```tsx
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1${start ? `&start=${start}` : ''}`}
|
||||
title={`YouTube: ${id}`}
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
// no sandbox attr — the full YouTube embed API needs scripts+same-origin, and
|
||||
// youtube-nocookie is already an isolated origin. Sandbox breaks fullscreen.
|
||||
/>
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **`youtube-nocookie.com`** is the reduced-tracking variant and should be the default.
|
||||
- **Don't sandbox**. The YouTube player relies on `postMessage` and same-origin window access for its controls; sandboxing breaks fullscreen and picture-in-picture. The iframe is already origin-isolated.
|
||||
- **CSP**: Cinny's `vite.config.ts` / `public/config.json` don't set CSP headers — the deployment host does. owl.cx must allow `frame-src https://www.youtube-nocookie.com https://www.youtube.com` (and the `i.ytimg.com` image host). Flag this for deployment.
|
||||
|
||||
### Component sketch
|
||||
|
||||
```tsx
|
||||
// src/owl/components/YouTubeEmbedCard.tsx
|
||||
export const YouTubeEmbedCard = as<'div', { url: string }>(({ url, ...p }, ref) => {
|
||||
const id = getYouTubeId(url);
|
||||
const start = getYouTubeStart(url);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
if (!id) return null;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100" {...p} ref={ref}>
|
||||
<div className={css.YouTubeFrame}>
|
||||
{playing ? (
|
||||
<iframe {...see above with id + start} />
|
||||
) : (
|
||||
<button onClick={() => setPlaying(true)}>
|
||||
<img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} loading="lazy" />
|
||||
<PlayOverlay />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Text as="a" href={url} target="_blank" rel="noreferrer">{url}</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Aspect ratio: 16:9 (`aspect-ratio: 16 / 9` in CSS) with a sensible `max-width` (e.g. 480px) so it doesn't dominate the timeline.
|
||||
|
||||
---
|
||||
|
||||
## Part B — Direct Video URLs (9gag, etc.)
|
||||
|
||||
### Detection
|
||||
|
||||
Mirror `isImageUrl` exactly:
|
||||
|
||||
```ts
|
||||
// src/owl/utils/videoUrl.ts
|
||||
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; }
|
||||
}
|
||||
```
|
||||
|
||||
**About 9gag specifically**: their `.webp` links served under `/photo/` are actually animated images (not video). The `.mp4` variant (`aYQnPXN_460svav1.mp4`) is a true MP4 and needs `<video>`. The path-segment `photo` is misleading — extension is what matters. Our image-extension list already handles `.webp`, so animated WebP 9gag links will flow through `ExternalImageCard` today (the `<img>` tag will animate them natively). MP4 variants will flow through the new video card. No special 9gag detection needed.
|
||||
|
||||
### Rendering
|
||||
|
||||
No facade needed — `<video>` with `preload="metadata"` only downloads the first few KB (metadata + poster frame). Users click play to stream.
|
||||
|
||||
```tsx
|
||||
// src/owl/components/ExternalVideoCard.tsx
|
||||
export const ExternalVideoCard = as<'div', { url: string }>(({ url, ...p }, ref) => {
|
||||
const [error, setError] = useState(false);
|
||||
if (error) return null;
|
||||
return (
|
||||
<Box direction="Column" gap="100" {...p} ref={ref}>
|
||||
<video
|
||||
className={css.ExternalVideo}
|
||||
src={url}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
<Text as="a" href={url} target="_blank" rel="noreferrer">{url}</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
We can reuse the existing `<Video>` wrapper from `src/app/components/media/Video.tsx` if we want shared styling, but a new card mirroring `ExternalImageCard` is simpler and keeps all owl code in `src/owl/`.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- **CORS / Hotlinking**: Some hosts (including Cloudflare sites) block hotlinked video with `Referer` checks. 9cache.com currently allows it — easy to verify empirically. If a host blocks us, `<video onError>` falls back (card hides itself). No leaking user IP because the threat model for owl is single-server — same as external images.
|
||||
- **Autoplay**: Do **not** autoplay. Browsers require `muted` for autoplay and it's user-hostile. `preload="metadata"` gives a poster frame without streaming the whole file.
|
||||
- **Mobile data**: `preload="metadata"` is the right default; don't change it to `auto`.
|
||||
|
||||
---
|
||||
|
||||
## Part C — Unified Wiring
|
||||
|
||||
### Setting
|
||||
|
||||
```ts
|
||||
// src/app/state/settings.ts
|
||||
export type ExternalVideoMode = 'always' | 'homeserver' | 'never';
|
||||
|
||||
// add to Settings interface:
|
||||
externalVideos: ExternalVideoMode;
|
||||
|
||||
// add to defaultSettings:
|
||||
externalVideos: 'never', // upstream-safe default
|
||||
```
|
||||
|
||||
owl.cx `config.json` overrides to `'homeserver'` (or `'always'`) via the same `defaultSettings` mechanism that external-images already uses.
|
||||
|
||||
Open question: **single setting or two?** My recommendation is **one setting `externalVideos` covering both YouTube and direct video**, because:
|
||||
|
||||
- They have the same privacy/trust model from the user's point of view ("do I trust external video hosts?").
|
||||
- Two toggles create decision fatigue for a feature that's largely "on or off".
|
||||
- If we later want finer control, splitting later is easier than merging later.
|
||||
|
||||
If we ever need YouTube-specific controls (e.g. "block YouTube but allow direct video" for a super-privacy-conscious user), we can add it then. Don't design for the hypothetical.
|
||||
|
||||
### Hook
|
||||
|
||||
```ts
|
||||
// src/owl/hooks/useAllowExternalVideos.ts (one-to-one copy of useAllowExternalImages)
|
||||
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;
|
||||
return room.roomId.split(':')[1] === mx.getDomain();
|
||||
}, [externalVideos, room.roomId, mx]);
|
||||
}
|
||||
```
|
||||
|
||||
### Dispatch in `RenderMessageContent.tsx`
|
||||
|
||||
The current branch (lines 63-84):
|
||||
|
||||
```ts
|
||||
const imageUrls = filteredUrls.filter(isImageUrl);
|
||||
const otherUrls = filteredUrls.filter((url) => !isImageUrl(url));
|
||||
```
|
||||
|
||||
Becomes:
|
||||
|
||||
```ts
|
||||
const imageUrls = filteredUrls.filter(isImageUrl);
|
||||
const youtubeUrls = filteredUrls.filter((u) => !isImageUrl(u) && getYouTubeId(u));
|
||||
const videoUrls = filteredUrls.filter((u) => !isImageUrl(u) && !getYouTubeId(u) && isVideoUrl(u));
|
||||
const otherUrls = filteredUrls.filter(
|
||||
(u) => !isImageUrl(u) && !getYouTubeId(u) && !isVideoUrl(u)
|
||||
);
|
||||
```
|
||||
|
||||
And render each list with its card. **Gating**: `RenderMessageContent` doesn't currently take `allowExternalVideos` as a prop — we'd either add one (mirroring the eventual external-images gating if/when it lands here), or gate inside the new card component by reading the hook. Reading inside the card is simpler but requires the card to know its room context. Passing a prop down from `RoomTimeline` is cleaner and matches how `urlPreview` already flows. **Recommendation: prop.**
|
||||
|
||||
The prop flow would be:
|
||||
|
||||
- `RoomTimeline.tsx:451` — already calls `useAllowExternalImages(mx, room)`. Add a sibling `useAllowExternalVideos(mx, room)`.
|
||||
- `RoomTimeline.tsx` — pass `allowExternalVideos` into `RenderMessageContent` (need to find where `RenderMessageContent` is rendered in the timeline and thread it through; currently `allowExternalImages` goes into `getReactCustomHtmlParser` at line 533, not into `RenderMessageContent`, because images are gated inside HTML parsing. Video gating is different — it gates URL-derived embeds, not parsed HTML — so we pass directly to `RenderMessageContent`).
|
||||
|
||||
### Settings UI
|
||||
|
||||
In `General.tsx`, copy `SelectExternalImages` (lines 882-954) to `SelectExternalVideos`, and add a `SettingTile` row mirroring lines 1043-1048:
|
||||
|
||||
```tsx
|
||||
<SequenceCard ...>
|
||||
<SettingTile title="External Videos" after={<SelectExternalVideos />} />
|
||||
</SequenceCard>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Touched
|
||||
|
||||
| File | Kind | Change size |
|
||||
|------|------|-------------|
|
||||
| `src/owl/utils/videoUrl.ts` | NEW | `isVideoUrl`, `getYouTubeId`, `getYouTubeStart` |
|
||||
| `src/owl/components/YouTubeEmbedCard.tsx` | NEW | Facade + iframe |
|
||||
| `src/owl/components/YouTubeEmbedCard.css.ts` | NEW | Aspect-ratio, thumbnail, play button |
|
||||
| `src/owl/components/ExternalVideoCard.tsx` | NEW | `<video>` card |
|
||||
| `src/owl/components/ExternalVideoCard.css.ts` | NEW | Sizing |
|
||||
| `src/owl/hooks/useAllowExternalVideos.ts` | NEW | Mirror of images hook |
|
||||
| `src/app/state/settings.ts` | edit | +2 lines (type + default) |
|
||||
| `src/app/components/RenderMessageContent.tsx` | edit | ~10 lines: import cards, filter branches, render |
|
||||
| `src/app/features/room/RoomTimeline.tsx` | edit | ~2 lines: add hook call, pass prop |
|
||||
| `src/app/features/settings/general/General.tsx` | edit | ~80 lines: copy `SelectExternalImages` → `SelectExternalVideos`, add tile row |
|
||||
|
||||
Upstream surface area: the three `src/app/...` edits. All feature logic (detection, rendering, policy hook, both cards, both CSS files) lives in `src/owl/`.
|
||||
|
||||
**Merge risk: LOW.** Pure additive change. No sanitizer or HTML-parser changes. If upstream touches `renderUrlsPreview` in `RenderMessageContent.tsx`, the branch addition is mechanical to re-apply.
|
||||
|
||||
---
|
||||
|
||||
## Security Summary
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---------|------------|
|
||||
| YouTube tracking | `youtube-nocookie.com`, facade (no iframe until user click), `referrerPolicy="strict-origin-when-cross-origin"` |
|
||||
| iframe XSS | YouTube iframe is on a separate origin — no DOM access to our app. No sandbox needed (and it breaks fullscreen). |
|
||||
| Video host IP leak | Same as external images: single-server threat model, already accepted by owl. Setting defaults to `'never'` for upstream. |
|
||||
| Video MIME spoofing | Browser validates MIME before playback; `<video>` rejects non-video payloads. `onError` hides the card. |
|
||||
| CSP | Deployment-side: owl.cx host config must permit `frame-src youtube-nocookie.com`, `img-src i.ytimg.com`, `media-src *` (or allowlist). Flag for deploy. |
|
||||
| Malicious autoplay | We never autoplay until user clicks. |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation & Priority
|
||||
|
||||
**Build this as one PR**, in this order:
|
||||
|
||||
1. `src/owl/utils/videoUrl.ts` + unit-style smoke test (throwaway URLs in dev console is fine — this is a client app, no test infra being set up for this).
|
||||
2. `ExternalVideoCard` (easier, mostly static markup).
|
||||
3. `YouTubeEmbedCard` with facade.
|
||||
4. Settings wiring + UI row.
|
||||
5. Manual test on owl.cx dev with: YouTube watch link, `youtu.be` short link, link with `?t=` timestamp, `youtube.com/shorts/`, a 9gag mp4, an arbitrary `.webm`, and a broken URL.
|
||||
|
||||
Overall risk: LOW. The pattern is copy-paste from external images, the only genuinely new code is the YouTube facade and the setting row.
|
||||
|
||||
One thing to explicitly decide with the user before implementing: **single `externalVideos` setting vs. split `youtubeEmbed` + `directVideoEmbed`**. My recommendation is single; flagging it so we don't bikeshed after the PR is up.
|
||||
@@ -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,
|
||||
|
||||
24
src/owl/components/ExternalVideoCard.css.tsx
Normal file
24
src/owl/components/ExternalVideoCard.css.tsx
Normal file
@@ -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',
|
||||
},
|
||||
]);
|
||||
40
src/owl/components/ExternalVideoCard.tsx
Normal file
40
src/owl/components/ExternalVideoCard.tsx
Normal file
@@ -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>
|
||||
);
|
||||
});
|
||||
83
src/owl/components/YouTubeEmbedCard.css.tsx
Normal file
83
src/owl/components/YouTubeEmbedCard.css.tsx
Normal file
@@ -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',
|
||||
},
|
||||
]);
|
||||
73
src/owl/components/YouTubeEmbedCard.tsx
Normal file
73
src/owl/components/YouTubeEmbedCard.tsx
Normal file
@@ -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>
|
||||
);
|
||||
});
|
||||
14
src/owl/hooks/useAllowExternalVideos.ts
Normal file
14
src/owl/hooks/useAllowExternalVideos.ts
Normal file
@@ -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]);
|
||||
}
|
||||
46
src/owl/utils/videoUrl.ts
Normal file
46
src/owl/utils/videoUrl.ts
Normal file
@@ -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