feat: richer link preview cards and user local time display
P2-7: Domain-specific URL preview cards — YouTube shows mqdefault.jpg thumbnail with ▶ play overlay + title; GitHub shows inline SVG icon + owner/repo parsed from og:title + star/language info from og:description; generic cards get a Google favicon when og:image is absent; empty cards (no title or description) are suppressed entirely P2-9: Live local time in user profiles — useLocalTime(timezone, hour12) uses Intl.DateTimeFormat with the user's m.tz IANA zone; updates every 60s; shows clock icon + formatted time + timezone abbreviation (EST/JST etc.) in dim text; respects existing hour24Clock setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,3 +47,94 @@ export const UrlPreviewDescription = style([
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
// YouTube card styles
|
||||
export const YouTubeThumbnailWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
width: toRem(160),
|
||||
height: toRem(90),
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.Surface.Container,
|
||||
|
||||
':hover': {
|
||||
filter: 'brightness(0.85)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubeThumbnailImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubePlayOverlay = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const YouTubePlayButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(44),
|
||||
height: toRem(44),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
},
|
||||
]);
|
||||
|
||||
// GitHub card styles
|
||||
export const GitHubIconWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
flexShrink: 0,
|
||||
width: toRem(72),
|
||||
minHeight: toRem(72),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.8,
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
]);
|
||||
|
||||
// Generic card favicon styles
|
||||
export const GenericFaviconWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S100,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
]);
|
||||
|
||||
export const GenericFaviconImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(16),
|
||||
height: toRem(16),
|
||||
flexShrink: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import * as css from './UrlPreviewCard.css';
|
||||
import * as previewCss from './UrlPreview.css';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
@@ -18,6 +19,281 @@ import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
|
||||
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();
|
||||
@@ -33,7 +309,20 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse) => {
|
||||
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'] || '',
|
||||
@@ -43,66 +332,37 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
'scale',
|
||||
false,
|
||||
);
|
||||
|
||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||
|
||||
return (
|
||||
<>
|
||||
{thumbUrl && (
|
||||
<UrlPreviewImg
|
||||
src={thumbUrl}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)}
|
||||
onClick={() => setViewer(true)}
|
||||
/>
|
||||
)}
|
||||
{imgUrl && (
|
||||
<ImageOverlay
|
||||
src={imgUrl}
|
||||
alt={prev['og:title']}
|
||||
viewer={viewer}
|
||||
requestClose={() => {
|
||||
setViewer(false);
|
||||
}}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
|
||||
{tryDecodeURIComponent(url)}
|
||||
</Text>
|
||||
<Text truncate priority="400">
|
||||
<b>{prev['og:title']}</b>
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
|
||||
</Text>
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
<GenericCard
|
||||
url={url}
|
||||
prev={prev}
|
||||
onOpenViewer={() => setViewer(true)}
|
||||
viewer={viewer}
|
||||
onCloseViewer={() => setViewer(false)}
|
||||
thumbUrl={thumbUrl}
|
||||
imgUrl={imgUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref}>
|
||||
{previewStatus.status === AsyncStatus.Success ? (
|
||||
renderContent(previewStatus.data)
|
||||
) : (
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
)}
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
</UrlPreview>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,6 +21,9 @@ import { UserPresence } from '../../hooks/useUserPresence';
|
||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { ImageViewer } from '../image-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useLocalTime } from '../../hooks/useLocalTime';
|
||||
|
||||
type UserHeroProps = {
|
||||
userId: string;
|
||||
@@ -107,6 +110,8 @@ export function UserHeroName({
|
||||
timezone,
|
||||
}: UserHeroNameProps) {
|
||||
const username = getMxIdLocalPart(userId);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const localTimeInfo = useLocalTime(timezone, !hour24Clock);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" gap="0">
|
||||
@@ -135,10 +140,12 @@ export function UserHeroName({
|
||||
@{username}
|
||||
</Text>
|
||||
</Box>
|
||||
{timezone && (
|
||||
{localTimeInfo && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||
<Icon size="50" src={Icons.Clock} />
|
||||
<Text size="T200" className={classNames(BreakWord, LineClamp2)} style={{ opacity: 0.6 }}>
|
||||
{timezone}
|
||||
{localTimeInfo.time}
|
||||
{localTimeInfo.abbr && <span style={{ opacity: 0.7 }}>{` ${localTimeInfo.abbr}`}</span>}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function formatLocalTime(timezone: string, hour12: boolean): string | undefined {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
}).format(new Date());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezoneAbbr(timezone: string): string | undefined {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
.formatToParts(new Date())
|
||||
.find((p) => p.type === 'timeZoneName')?.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalTimeInfo = {
|
||||
time: string;
|
||||
abbr: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current time (and timezone abbreviation) in the given IANA
|
||||
* timezone, updated every minute. Returns undefined when timezone is
|
||||
* undefined or invalid.
|
||||
*/
|
||||
export function useLocalTime(
|
||||
timezone: string | undefined,
|
||||
hour12: boolean = true,
|
||||
): LocalTimeInfo | undefined {
|
||||
const compute = useCallback((): LocalTimeInfo | undefined => {
|
||||
if (!timezone) return undefined;
|
||||
const time = formatLocalTime(timezone, hour12);
|
||||
if (!time) return undefined;
|
||||
return { time, abbr: getTimezoneAbbr(timezone) };
|
||||
}, [timezone, hour12]);
|
||||
|
||||
const [info, setInfo] = useState<LocalTimeInfo | undefined>(compute);
|
||||
|
||||
useEffect(() => {
|
||||
setInfo(compute());
|
||||
const id = window.setInterval(() => setInfo(compute()), 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [compute]);
|
||||
|
||||
return info;
|
||||
}
|
||||
Reference in New Issue
Block a user