feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
265
packages/shared/src/components/channel/LinkEmbed.tsx
Normal file
265
packages/shared/src/components/channel/LinkEmbed.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowSquareOut, Play } from '@phosphor-icons/react';
|
||||
import { useAction } from 'convex/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
import styles from './LinkEmbed.module.css';
|
||||
|
||||
interface UrlPreview {
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
const VIDEO_HOSTS = [
|
||||
'youtube.com',
|
||||
'youtu.be',
|
||||
'www.youtube.com',
|
||||
'vimeo.com',
|
||||
'www.vimeo.com',
|
||||
'twitch.tv',
|
||||
'www.twitch.tv',
|
||||
'dailymotion.com',
|
||||
'www.dailymotion.com',
|
||||
];
|
||||
|
||||
const DIRECT_VIDEO_EXTS = ['.mp4', '.webm', '.ogg', '.mov'];
|
||||
const DIRECT_IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
|
||||
|
||||
function isDirectMedia(url: string): 'video' | 'image' | null {
|
||||
try {
|
||||
const pathname = new URL(url).pathname.toLowerCase();
|
||||
if (DIRECT_VIDEO_EXTS.some((ext) => pathname.endsWith(ext))) return 'video';
|
||||
if (DIRECT_IMAGE_EXTS.some((ext) => pathname.endsWith(ext))) return 'image';
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isVideoUrl(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return VIDEO_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Module-scope cache so we don't refetch the same URL every re-render.
|
||||
// `null` means "we tried and there's no preview" — we cache that too to
|
||||
// avoid hammering the fetcher for URLs that will never resolve.
|
||||
const previewCache = new Map<string, UrlPreview | null>();
|
||||
|
||||
function normaliseMetadata(raw: any): UrlPreview | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
// Accept a few possible shapes: the Electron preload IPC returns
|
||||
// `{ title, description, image, siteName, url }`; matrix-js-sdk style
|
||||
// returns `{ 'og:title', 'og:description', 'og:image', 'og:site_name' }`.
|
||||
const title =
|
||||
raw.title ?? raw['og:title'] ?? raw.ogTitle ?? undefined;
|
||||
const description =
|
||||
raw.description ?? raw['og:description'] ?? raw.ogDescription ?? undefined;
|
||||
const imageUrl =
|
||||
raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined;
|
||||
const siteName =
|
||||
raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined;
|
||||
|
||||
if (!title && !description && !imageUrl) return null;
|
||||
return { title, description, imageUrl, siteName };
|
||||
}
|
||||
|
||||
function useUrlPreview(url: string): UrlPreview | null {
|
||||
const platform = usePlatform();
|
||||
const fetchPreviewAction = useAction(api.links.fetchPreview);
|
||||
const [preview, setPreview] = useState<UrlPreview | null>(
|
||||
previewCache.get(url) ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache.has(url)) {
|
||||
setPreview(previewCache.get(url) ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// Prefer the platform-native fetcher when available (Electron
|
||||
// ships one via IPC — no CORS, no round-trip through the
|
||||
// backend). On web, `fetchMetadata` returns null due to CORS,
|
||||
// so we fall through to the Convex Node action which performs
|
||||
// the fetch server-side.
|
||||
let result: UrlPreview | null = null;
|
||||
const fetcher = platform?.links?.fetchMetadata;
|
||||
if (typeof fetcher === 'function') {
|
||||
try {
|
||||
const raw = await fetcher(url);
|
||||
result = normaliseMetadata(raw);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
try {
|
||||
const raw = await fetchPreviewAction({ url });
|
||||
if (!cancelled) result = normaliseMetadata(raw);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
previewCache.set(url, result);
|
||||
setPreview(result);
|
||||
} catch {
|
||||
if (!cancelled) previewCache.set(url, null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url, platform, fetchPreviewAction]);
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image' }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
if (type === 'video') {
|
||||
const handlePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.controls = true;
|
||||
void videoRef.current.play();
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.embed}>
|
||||
<div className={styles.directVideoWrapper}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={styles.directVideo}
|
||||
src={url}
|
||||
preload="metadata"
|
||||
onPause={() => {
|
||||
if (videoRef.current && videoRef.current.ended) {
|
||||
videoRef.current.controls = false;
|
||||
setPlaying(false);
|
||||
}
|
||||
}}
|
||||
onEnded={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.controls = false;
|
||||
setPlaying(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!playing && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.directPlayOverlay}
|
||||
onClick={handlePlay}
|
||||
>
|
||||
<Play size={36} weight="fill" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.embed}>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
className={styles.directImage}
|
||||
src={url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinkEmbedProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function LinkEmbed({ url }: LinkEmbedProps) {
|
||||
const directType = isDirectMedia(url);
|
||||
if (directType) {
|
||||
return <DirectMediaEmbed url={url} type={directType} />;
|
||||
}
|
||||
|
||||
const preview = useUrlPreview(url);
|
||||
|
||||
// If the platform has no metadata fetcher (or it returned null / threw),
|
||||
// degrade gracefully to nothing — the raw link is already rendered in
|
||||
// the message body by MessageContent.
|
||||
if (!preview) return null;
|
||||
|
||||
const isVideo = isVideoUrl(url);
|
||||
const hasImage = !!preview.imageUrl;
|
||||
|
||||
return (
|
||||
<div className={styles.embed}>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.embedContent}>
|
||||
{preview.siteName && (
|
||||
<div className={styles.provider}>{preview.siteName}</div>
|
||||
)}
|
||||
|
||||
{preview.title && (
|
||||
<a
|
||||
className={styles.title}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{preview.title}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{preview.description && (
|
||||
<div className={styles.description}>{preview.description}</div>
|
||||
)}
|
||||
|
||||
{hasImage && (
|
||||
<div className={styles.mediaContainer}>
|
||||
<img
|
||||
className={styles.mediaImage}
|
||||
src={preview.imageUrl}
|
||||
alt={preview.title || ''}
|
||||
loading="lazy"
|
||||
/>
|
||||
{isVideo && (
|
||||
<a
|
||||
className={styles.mediaOverlay}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className={styles.mediaControls}>
|
||||
<button type="button" className={styles.playButton}>
|
||||
<Play size={28} weight="fill" />
|
||||
</button>
|
||||
<button type="button" className={styles.openButton}>
|
||||
<ArrowSquareOut size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkEmbed;
|
||||
Reference in New Issue
Block a user