Files
cinny/src/app/components/url-preview/UrlPreviewCard.tsx
T

482 lines
15 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect, useRef, useState } from 'react';
2023-10-30 07:14:58 +11:00
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
2023-10-30 07:14:58 +11:00
import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import * as css from './UrlPreviewCard.css';
import * as previewCss from './UrlPreview.css';
import { tryDecodeURIComponent } from '../../utils/dom';
2024-09-07 21:45:55 +08:00
import { mxcUrlToHttp } from '../../utils/matrix';
2024-09-09 18:45:20 +10:00
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
2023-10-30 07:14:58 +11:00
const linkStyles = { color: color.Success.Main };
// ---------------------------------------------------------------------------
// Helpers — URL parsing & variant detection
// ---------------------------------------------------------------------------
type CardVariant = 'youtube' | 'github' | 'generic';
function getYouTubeVideoId(url: string): string | null {
try {
const parsed = new URL(url);
const { hostname, pathname, searchParams } = parsed;
// youtu.be/<id>
if (hostname === 'youtu.be') {
const id = pathname.slice(1).split('/')[0];
return id || null;
}
// youtube.com/watch?v=<id>
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
if (pathname === '/watch') {
return searchParams.get('v');
}
// youtube.com/shorts/<id>
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (shortsMatch) return shortsMatch[1];
}
} catch {
// ignore malformed URLs
}
return null;
}
function isGitHubRepo(url: string): boolean {
try {
const parsed = new URL(url);
const { hostname, pathname } = parsed;
if (hostname !== 'github.com' && hostname !== 'www.github.com') return false;
// Exactly two path segments: /<owner>/<repo> (no deeper pages)
const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean);
return parts.length === 2;
} catch {
return false;
}
}
function getCardVariant(url: string): CardVariant {
if (getYouTubeVideoId(url) !== null) return 'youtube';
if (isGitHubRepo(url)) return 'github';
return 'generic';
}
function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
// ---------------------------------------------------------------------------
// GitHub SVG icon (inline, no external dependency)
// ---------------------------------------------------------------------------
function GitHubIcon({ size = 24 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
role="img"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Card variant renderers
// ---------------------------------------------------------------------------
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const videoId = getYouTubeVideoId(url)!;
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
return (
<>
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.YouTubeThumbnailWrapper}
aria-label={`Play on YouTube: ${title}`}
>
<img
className={previewCss.YouTubeThumbnailImg}
src={thumbnailSrc}
alt={title}
loading="lazy"
/>
<div className={previewCss.YouTubePlayOverlay}>
<div className={previewCss.YouTubePlayButton}>
<Icon size="400" src={Icons.Play} />
</div>
</div>
</a>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
YouTube
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
// GitHub og:title is usually "owner/repo: short description" — split on ': '
const colonIdx = title.indexOf(': ');
const repoName = colonIdx !== -1 ? title.slice(0, colonIdx) : title;
const repoDesc = colonIdx !== -1 ? title.slice(colonIdx + 2) : description;
return (
<>
<Box
shrink="No"
alignItems="Center"
justifyContent="Center"
className={previewCss.GitHubIconWrapper}
>
<GitHubIcon size={28} />
</Box>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
GitHub
</Text>
{repoName && (
<Text truncate priority="400">
<b>{repoName}</b>
</Text>
)}
{repoDesc && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{repoDesc}</UrlPreviewDescription>
</Text>
)}
{description && description !== repoDesc && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
function GenericCard({
url,
prev,
onOpenViewer,
viewer,
onCloseViewer,
thumbUrl,
imgUrl,
}: {
url: string;
prev: IPreviewUrlResponse;
onOpenViewer: () => void;
viewer: boolean;
onCloseViewer: () => void;
thumbUrl: string | null;
imgUrl: string | null;
}) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
const domain = getDomain(url);
const faviconSrc = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=16`;
return (
<>
{thumbUrl && (
<UrlPreviewImg
src={thumbUrl}
alt={prev['og:title']}
title={prev['og:title']}
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
onClick={onOpenViewer}
/>
)}
{imgUrl && (
<ImageOverlay
src={imgUrl}
alt={prev['og:title']}
viewer={viewer}
requestClose={onCloseViewer}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{!thumbUrl && (
<img
className={previewCss.GenericFaviconImg}
src={faviconSrc}
alt=""
aria-hidden="true"
loading="lazy"
style={{ marginRight: '4px', verticalAlign: 'text-bottom' }}
/>
)}
{siteName ? `${siteName} | ` : ''}
{tryDecodeURIComponent(url)}
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// Main UrlPreviewCard component
// ---------------------------------------------------------------------------
export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
({ url, ts, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewer, setViewer] = useState(false);
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]),
);
2024-01-21 23:50:56 +11:00
useEffect(() => {
loadPreview().catch(() => {});
}, [loadPreview]);
2023-10-30 07:14:58 +11:00
if (previewStatus.status === AsyncStatus.Error) return null;
2023-10-30 07:14:58 +11:00
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
const variant = getCardVariant(url);
if (variant === 'youtube') {
return <YouTubeCard url={url} prev={prev} />;
}
if (variant === 'github') {
return <GitHubCard url={url} prev={prev} />;
}
// Generic fallback — skip empty cards
if (!prev['og:title'] && !prev['og:description']) return null;
const thumbUrl = mxcUrlToHttp(
mx,
prev['og:image'] || '',
useAuthentication,
256,
256,
'scale',
false,
);
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
2023-10-30 07:14:58 +11:00
return (
<GenericCard
url={url}
prev={prev}
onOpenViewer={() => setViewer(true)}
viewer={viewer}
onCloseViewer={() => setViewer(false)}
thumbUrl={thumbUrl}
imgUrl={imgUrl}
/>
);
};
2023-10-30 07:14:58 +11:00
// Don't render the card wrapper when content is empty (loaded but nothing to show)
if (previewStatus.status === AsyncStatus.Success) {
const content = renderContent(previewStatus.data);
if (content === null) return null;
return (
<UrlPreview {...props} ref={ref}>
{content}
</UrlPreview>
);
}
2023-10-30 07:14:58 +11:00
return (
<UrlPreview {...props} ref={ref}>
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
</UrlPreview>
2023-10-30 07:14:58 +11:00
);
},
);
2023-10-30 07:14:58 +11:00
export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
const scrollRef = useRef<HTMLDivElement>(null);
const backAnchorRef = useRef<HTMLDivElement>(null);
const frontAnchorRef = useRef<HTMLDivElement>(null);
const [backVisible, setBackVisible] = useState(true);
const [frontVisible, setFrontVisible] = useState(true);
const intersectionObserver = useIntersectionObserver(
useCallback((entries) => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
if (backEntry) {
setBackVisible(backEntry.isIntersecting);
}
if (frontEntry) {
setFrontVisible(frontEntry.isIntersecting);
}
}, []),
useCallback(
() => ({
root: scrollRef.current,
rootMargin: '10px',
}),
[],
),
2023-10-30 07:14:58 +11:00
);
useEffect(() => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
if (backAnchor) intersectionObserver?.observe(backAnchor);
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
return () => {
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
};
}, [intersectionObserver]);
const handleScrollBack = () => {
const scroll = scrollRef.current;
if (!scroll) return;
const { offsetWidth, scrollLeft } = scroll;
scroll.scrollTo({
left: scrollLeft - offsetWidth / 1.3,
behavior: 'smooth',
});
};
const handleScrollFront = () => {
const scroll = scrollRef.current;
if (!scroll) return;
const { offsetWidth, scrollLeft } = scroll;
scroll.scrollTo({
left: scrollLeft + offsetWidth / 1.3,
behavior: 'smooth',
});
};
return (
<Box
direction="Column"
{...props}
ref={ref}
style={{ marginTop: config.space.S200, position: 'relative' }}
>
<Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
<Box shrink="No" alignItems="Center">
<div ref={backAnchorRef} />
{!backVisible && (
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
aria-label="Previous preview"
2023-10-30 07:14:58 +11:00
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={handleScrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
<Box alignItems="Inherit" gap="200">
{children}
{!frontVisible && (
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
aria-label="Next preview"
2023-10-30 07:14:58 +11:00
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={handleScrollFront}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
<div ref={frontAnchorRef} />
</Box>
</Box>
</Scroll>
</Box>
);
});