From 01ba24df12e7488a20000a9d798974976ff978f1 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 3 Jun 2026 23:15:29 -0400 Subject: [PATCH] feat: richer link preview cards and user local time display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/url-preview/UrlPreview.css.tsx | 91 +++++ .../components/url-preview/UrlPreviewCard.tsx | 366 +++++++++++++++--- src/app/components/user-profile/UserHero.tsx | 11 +- src/app/hooks/useLocalTime.ts | 59 +++ 4 files changed, 472 insertions(+), 55 deletions(-) create mode 100644 src/app/hooks/useLocalTime.ts diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index cd0b25284..ad34cc73f 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -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, + }, +]); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ca8b1236b..7d984de72 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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/ + if (hostname === 'youtu.be') { + const id = pathname.slice(1).split('/')[0]; + return id || null; + } + + // youtube.com/watch?v= + if (hostname === 'www.youtube.com' || hostname === 'youtube.com') { + if (pathname === '/watch') { + return searchParams.get('v'); + } + // youtube.com/shorts/ + 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: // (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 ( + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + <> + + {title} +
+
+ +
+
+
+ + + YouTube + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +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 ( + <> + + + + + + GitHub + + {repoName && ( + + {repoName} + + )} + {repoDesc && ( + + {repoDesc} + + )} + {description && description !== repoDesc && ( + + {description} + + )} + + + ); +} + +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 && ( + onEnterOrSpace(() => onOpenViewer())(evt)} + onClick={onOpenViewer} + /> + )} + {imgUrl && ( + } + /> + )} + + + {!thumbUrl && ( + + )} + {siteName ? `${siteName} | ` : ''} + {tryDecodeURIComponent(url)} + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// 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 ; + } + + if (variant === 'github') { + return ; + } + + // 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 && ( - onEnterOrSpace(() => setViewer(true))(evt)} - onClick={() => setViewer(true)} - /> - )} - {imgUrl && ( - { - setViewer(false); - }} - renderViewer={(p) => } - /> - )} - - - {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `} - {tryDecodeURIComponent(url)} - - - {prev['og:title']} - - - {prev['og:description']} - - - + 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 ( + + {content} + + ); + } + return ( - {previewStatus.status === AsyncStatus.Success ? ( - renderContent(previewStatus.data) - ) : ( - - - - )} + + + ); }, diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index d3e0c92df..dd10a7aeb 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -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 ( @@ -135,10 +140,12 @@ export function UserHeroName({ @{username} - {timezone && ( + {localTimeInfo && ( + - {timezone} + {localTimeInfo.time} + {localTimeInfo.abbr && {` ${localTimeInfo.abbr}`}} )} diff --git a/src/app/hooks/useLocalTime.ts b/src/app/hooks/useLocalTime.ts new file mode 100644 index 000000000..785855a65 --- /dev/null +++ b/src/app/hooks/useLocalTime.ts @@ -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(compute); + + useEffect(() => { + setInfo(compute()); + const id = window.setInterval(() => setInfo(compute()), 60_000); + return () => window.clearInterval(id); + }, [compute]); + + return info; +}