diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index c476df573..38ece5a44 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -88,8 +88,9 @@ export function RenderMessageContent({ }: RenderMessageContentProps) { const renderUrlsPreview = (urls: string[]) => { // Cap previews per message so a link-dump doesn't spawn dozens of preview - // fetches + iframes at once. - const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6); + // fetches + iframes at once. De-dupe first: a message linking the same URL + // twice would otherwise render sibling cards with identical React keys. + const filteredUrls = [...new Set(urls.filter((url) => !testMatrixTo(url)))].slice(0, 6); if (filteredUrls.length === 0) return undefined; return ( diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index aba926e57..ef3f10721 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -116,11 +116,15 @@ function isGitHubRepo(url: string): boolean { } } +// Keep these hosts + the /status(es) pattern in sync with getTweetId +// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has +// an extractable id but never routes to the Twitter card or "View post" embed. +const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']); + function isTwitter(url: string): boolean { try { const { hostname } = new URL(url); - const h = hostname.replace(/^www\./, ''); - return h === 'twitter.com' || h === 'x.com'; + return TWITTER_HOSTS.has(hostname.replace(/^www\./, '')); } catch { return false; } @@ -129,9 +133,8 @@ function isTwitter(url: string): boolean { function isTwitterTweet(url: string): boolean { try { const { hostname, pathname } = new URL(url); - const h = hostname.replace(/^www\./, ''); - if (h !== 'twitter.com' && h !== 'x.com') return false; - return /\/status\/\d+/.test(pathname); + if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false; + return /\/status(?:es)?\/\d+/.test(pathname); } catch { return false; } @@ -1499,9 +1502,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { } function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; - const artworkUrl = (prev['og:image'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + // Route through the homeserver like every other card — a raw og:image would + // be an mxc:// URI (broken ) on a standard HS, or an off-HS request that + // defeats the click-to-play facade on a nonstandard one. + const artworkUrl = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false) + : null; const spotifyType = getSpotifyType(url) ?? 'track'; const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1); @@ -1552,9 +1563,14 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) } function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; - const thumbnailUrl = (prev['og:image'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + const thumbnailUrl = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false) + : null; return ( <> @@ -1671,9 +1687,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse } function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const { t } = useTranslation(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; - const iconUrl = (prev['og:image'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + const iconUrl = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false) + : null; return ( <> @@ -1834,9 +1855,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon } function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; - const posterUrl = (prev['og:image'] as string | undefined) ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + const posterUrl = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false) + : null; return ( <> @@ -2075,28 +2101,34 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( // Interactive embeds (players, tweets) render in a wider, responsive card so // player chrome / tweet content isn't cramped or clipped. const embed = parseMediaEmbed(url, window.location.hostname); - const wide = !!embed || isTwitterTweet(url); + // Short "copy-link" links carry no id, so the embed is only resolvable from + // the homeserver's canonical og:url. Resolve it here so `wide` reflects the + // ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get + // the wide layout, not the cramped narrow one. + const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => { + if (embed) return embed; + const ogUrl = prev['og:url']; + return typeof ogUrl === 'string' && ogUrl !== url + ? parseMediaEmbed(ogUrl, window.location.hostname) + : null; + }; // Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content // in the card flex row; stack them on phones (no-op for the single-column // embed cards). Desktop keeps the row layout. const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url); - const cardClass = + const buildCardClass = (wide: boolean): string | undefined => [wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile] .filter(Boolean) .join(' ') || undefined; - const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => { + const renderContent = ( + prev: IPreviewUrlResponse, + resolvedEmbed: MediaEmbed | null, + ): React.ReactNode => { // Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/ - // Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile. - // Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be - // redirects) don't carry the id, so fall back to the canonical og:url that - // the homeserver already resolved when fetching the preview. - const ogUrl = prev['og:url']; - const resolvedEmbed = - embed ?? - (typeof ogUrl === 'string' && ogUrl !== url - ? parseMediaEmbed(ogUrl, window.location.hostname) - : null); + // Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play + // tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already + // folds in the og:url fallback for short "copy-link" share URLs. if (resolvedEmbed) { return ; } @@ -2189,17 +2221,23 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( // 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); + const prev = previewStatus.data; + const resolvedEmbed = resolveEmbed(prev); + const content = renderContent(prev, resolvedEmbed); if (content === null) return null; + // `wide` follows the resolved embed (incl. the og:url fallback), so a short + // link that resolves to a player still gets the wide layout. + const wide = !!resolvedEmbed || isTwitterTweet(url); return ( - + {content} ); } + // Loading/idle: no preview data yet, so base `wide` on the url-only embed. return ( - + diff --git a/src/app/utils/videoEmbed.test.ts b/src/app/utils/videoEmbed.test.ts index f9ac4f4a9..3aeb0cb0d 100644 --- a/src/app/utils/videoEmbed.test.ts +++ b/src/app/utils/videoEmbed.test.ts @@ -57,6 +57,10 @@ test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => { assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871'); assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345'); assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321'); + // a normal video with a trailing sub-path segment must NOT capture it as a hash + assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.hash, undefined); + assert.equal(getVimeoParts('https://vimeo.com/123456789/settings')?.hash, undefined); + assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.id, '123456789'); }); test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => { @@ -113,6 +117,7 @@ test('Dailymotion + Streamable', () => { assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde'); assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12'); assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path + assert.equal(getStreamableId('https://streamable.com/login'), null); // reserved page }); test('Twitch: channel / video / clip', () => { @@ -132,6 +137,10 @@ test('Twitch: channel / video / clip', () => { type: 'clip', value: 'CoolSlug', }); + // reserved utility pages are not channels + assert.equal(getTwitchTarget('https://twitch.tv/directory'), null); + assert.equal(getTwitchTarget('https://twitch.tv/settings'), null); + assert.equal(getTwitchTarget('https://twitch.tv/videos'), null); // bare /videos, not a channel }); test('Spotify target + height', () => { @@ -153,6 +162,9 @@ test('SoundCloud track detection', () => { assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile // on.soundcloud.com short links intentionally not handled (need oEmbed resolve) assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false); + assert.equal(isSoundCloudTrack('https://soundcloud.com/discover/xyz'), false); // site section + assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets'), false); // profile-tab listing + assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets/my-set'), true); // a real set }); test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => { @@ -287,6 +299,8 @@ test('Bluesky / Loom / Kick', () => { assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer'); assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed + assert.equal(getKickChannel('https://kick.com/browse'), null); // nav page, not a channel + assert.equal(getKickChannel('https://kick.com/following'), null); assert.ok( parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes( 'player.kick.com/streamer?autoplay=true', diff --git a/src/app/utils/videoEmbed.ts b/src/app/utils/videoEmbed.ts index 9cfbd2d53..748fa440a 100644 --- a/src/app/utils/videoEmbed.ts +++ b/src/app/utils/videoEmbed.ts @@ -66,8 +66,10 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null try { const { hostname, pathname } = new URL(url); if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null; - // Canonical /{id} or unlisted /{id}/{hash} - let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/); + // Canonical /{id} or unlisted /{id}/{hash}. The hash is a lowercase-hex token + // (constrain it so a normal video's trailing segment — /likes, /settings, a + // review slug — isn't captured as a bogus `h=` param that Vimeo then rejects). + let m = pathname.match(/^\/(\d+)(?:\/([0-9a-f]{6,}))?/); if (m) return { id: m[1], hash: m[2] }; // channels/groups/album share a trailing numeric video id m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/); @@ -192,12 +194,26 @@ export function getDailymotionId(url: string): string | null { // --- Streamable ----------------------------------------------------------- +// Streamable's own utility/first-path pages that are not video ids. +const STREAMABLE_RESERVED = new Set([ + 'e', + 'login', + 'signup', + 'settings', + 'account', + 'dashboard', + 'help', + 'terms', + 'privacy', + 'about', +]); + export function getStreamableId(url: string): string | null { try { const { hostname, pathname } = new URL(url); if (hostname.replace(/^www\./, '') !== 'streamable.com') return null; const m = pathname.match(/^\/([A-Za-z0-9]+)/); - return m && m[1] !== 'e' ? m[1] : null; + return m && !STREAMABLE_RESERVED.has(m[1].toLowerCase()) ? m[1] : null; } catch { return null; } @@ -210,6 +226,32 @@ export type TwitchTarget = | { type: 'video'; value: string } | { type: 'clip'; value: string }; +// Twitch's own reserved first-path segments — single-segment paths that are +// utility pages, not channels, and must NOT be embedded as `channel=`. +const TWITCH_RESERVED = new Set([ + 'directory', + 'videos', + 'settings', + 'subscriptions', + 'following', + 'followers', + 'friends', + 'inventory', + 'wallet', + 'drops', + 'prime', + 'turbo', + 'downloads', + 'jobs', + 'store', + 'search', + 'dashboard', + 'popout', + 'p', + 'u', + 'team', +]); + export function getTwitchTarget(url: string): TwitchTarget | null { try { const { hostname, pathname } = new URL(url); @@ -219,7 +261,9 @@ export function getTwitchTarget(url: string): TwitchTarget | null { if (h === 'twitch.tv' || h === 'm.twitch.tv') { if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] }; if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] }; - if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] }; + if (parts.length === 1 && parts[0] && !TWITCH_RESERVED.has(parts[0].toLowerCase())) { + return { type: 'channel', value: parts[0] }; + } } } catch { /* ignore */ @@ -248,6 +292,38 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str // --- SoundCloud ----------------------------------------------------------- +// SoundCloud's own site sections (first segment) that are never ``. +const SOUNDCLOUD_RESERVED = new Set([ + 'discover', + 'you', + 'stream', + 'search', + 'upload', + 'settings', + 'notifications', + 'messages', + 'tags', + 'charts', + 'people', + 'pages', + 'terms', + 'pro', +]); +// Profile tabs — `//` is a listing, not a single track (a real set +// is the deeper `//sets/`, which has length >= 3 and is allowed). +const SOUNDCLOUD_PROFILE_TABS = new Set([ + 'tracks', + 'sets', + 'albums', + 'reposts', + 'likes', + 'following', + 'followers', + 'comments', + 'popular-tracks', + 'toptracks', +]); + export function isSoundCloudTrack(url: string): boolean { try { const { hostname, pathname } = new URL(url); @@ -260,7 +336,11 @@ export function isSoundCloudTrack(url: string): boolean { .replace(/^\/+|\/+$/g, '') .split('/') .filter(Boolean); - return parts.length >= 2; + if (parts.length < 2) return false; + if (SOUNDCLOUD_RESERVED.has(parts[0].toLowerCase())) return false; + // `//` profile-tab listing (not a playable single track/set). + if (parts.length === 2 && SOUNDCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return false; + return true; } catch { return false; } @@ -391,6 +471,22 @@ export function getLoomId(url: string): string | null { // --- Kick (live channels only; VODs/clips have no clean iframe) ------------ +// Kick's own reserved first-path segments (nav pages, not channels). +const KICK_RESERVED = new Set([ + 'browse', + 'following', + 'category', + 'categories', + 'search', + 'messages', + 'subscriptions', + 'settings', + 'wallet', + 'help', + 'clips', + 'dashboard', +]); + export function getKickChannel(url: string): string | null { try { const { hostname, pathname } = new URL(url); @@ -399,7 +495,11 @@ export function getKickChannel(url: string): string | null { .replace(/^\/+|\/+$/g, '') .split('/') .filter(Boolean); - return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null; + return parts.length === 1 && + /^[A-Za-z0-9_]+$/.test(parts[0]) && + !KICK_RESERVED.has(parts[0].toLowerCase()) + ? parts[0] + : null; } catch { return null; }