2 Commits

Author SHA1 Message Date
47979718eb Fix responsive message images
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Constrain image attachments to their parent while preserving their aspect ratio.
2026-08-25 00:02:45 +10:00
862307ee7d Add TikTok player iframe
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use TikTok's supported player endpoint rather than injecting its oEmbed script.
2026-08-24 23:58:31 +10:00
4 changed files with 57 additions and 24 deletions

View File

@@ -262,7 +262,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
<AttachmentBox <AttachmentBox
style={{ style={{
width: toRem(width), width: toRem(width),
height: toRem(height), aspectRatio: `${width} / ${height}`,
['--media-h' as string]: `${height}px`, ['--media-h' as string]: `${height}px`,
}} }}
data-paarrot-media-height={height} data-paarrot-media-height={height}

View File

@@ -8,7 +8,7 @@ export const Attachment = recipe({
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
overflow: 'hidden', overflow: 'hidden',
maxWidth: toRem(400), maxWidth: `min(100%, ${toRem(400)})`,
}, },
variants: { variants: {
outlined: { outlined: {
@@ -33,7 +33,7 @@ export const AttachmentHeader = style({
export const AttachmentBox = style([ export const AttachmentBox = style([
DefaultReset, DefaultReset,
{ {
maxWidth: toRem(400), maxWidth: `min(100%, ${toRem(400)})`,
maxHeight: toRem(400), maxHeight: toRem(400),
overflow: 'hidden', overflow: 'hidden',
}, },

View File

@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#000', backgroundColor: '#000',
border: 'none',
display: 'block',
}, },
]); ]);

View File

@@ -42,6 +42,12 @@ export function isTikTokUrl(url: string): boolean {
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url)); return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
} }
function extractTikTokVideoId(url: string, embedHtml = ''): string | null {
const urlMatch = url.match(/\/video\/(\d+)/i) ?? url.match(/\/v\/(\d+)/i);
const embedMatch = embedHtml.match(/data-video-id=["'](\d+)["']/i);
return urlMatch?.[1] ?? embedMatch?.[1] ?? null;
}
/** /**
* Fetches TikTok video metadata using oEmbed API * Fetches TikTok video metadata using oEmbed API
* @param url The TikTok video URL * @param url The TikTok video URL
@@ -53,6 +59,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: string; authorUrl: string;
thumbnailUrl: string; thumbnailUrl: string;
embedHtml: string; embedHtml: string;
videoId: string | null;
} | null> { } | null> {
try { try {
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`; const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
@@ -67,6 +74,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: data.author_url || url, authorUrl: data.author_url || url,
thumbnailUrl: data.thumbnail_url || '', thumbnailUrl: data.thumbnail_url || '',
embedHtml: data.html || '', embedHtml: data.html || '',
videoId: extractTikTokVideoId(url, data.html || ''),
}; };
} catch { } catch {
return null; return null;
@@ -75,7 +83,15 @@ async function fetchTikTokOEmbed(url: string): Promise<{
type TikTokEmbedState = type TikTokEmbedState =
| { status: 'loading' } | { status: 'loading' }
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string } | {
status: 'loaded';
title: string;
authorName: string;
authorUrl: string;
thumbnailUrl: string;
embedHtml: string;
videoId: string | null;
}
| { status: 'error' }; | { status: 'error' };
type TikTokEmbedProps = { type TikTokEmbedProps = {
@@ -88,7 +104,7 @@ type TikTokEmbedProps = {
*/ */
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => { export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' }); const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
const [showEmbed, setShowEmbed] = useState(false); const [showPlayer, setShowPlayer] = useState(false);
// Fetch video info on mount // Fetch video info on mount
useEffect(() => { useEffect(() => {
@@ -105,6 +121,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
authorUrl: info.authorUrl, authorUrl: info.authorUrl,
thumbnailUrl: info.thumbnailUrl, thumbnailUrl: info.thumbnailUrl,
embedHtml: info.embedHtml, embedHtml: info.embedHtml,
videoId: info.videoId,
}); });
} else { } else {
setState({ status: 'error' }); setState({ status: 'error' });
@@ -169,7 +186,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
} }
// Loaded state - show thumbnail or embed // Loaded state - show thumbnail or embed
if (!showEmbed) { if (!showPlayer) {
return ( return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}> <Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}> <div className={css.TikTokEmbedHeader}>
@@ -203,23 +220,27 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
<button <button
type="button" type="button"
className={css.TikTokThumbnailButton} className={css.TikTokThumbnailButton}
onClick={() => setShowEmbed(true)} onClick={() => setShowPlayer(true)}
aria-label="Load TikTok video" aria-label="Play TikTok video"
> >
{state.thumbnailUrl && ( {state.thumbnailUrl ? (
<img <img
src={state.thumbnailUrl} src={state.thumbnailUrl}
alt={state.title} alt={state.title}
className={css.TikTokThumbnail} className={css.TikTokThumbnail}
crossOrigin="anonymous" crossOrigin="anonymous"
/> />
)} ) : null}
</button> </button>
</Box> </Box>
); );
} }
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML) const playerUrl = state.videoId
? `https://www.tiktok.com/player/v1/${state.videoId}?autoplay=1`
: null;
// Use TikTok's documented player instead of injecting the oEmbed script.
return ( return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}> <Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}> <div className={css.TikTokEmbedHeader}>
@@ -236,10 +257,20 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
{state.title} {state.title}
</Text> </Text>
</div> </div>
<div {playerUrl ? (
className={css.TikTokEmbedContainer} <iframe
dangerouslySetInnerHTML={{ __html: state.embedHtml }} className={css.TikTokEmbedContainer}
/> src={playerUrl}
title="TikTok video player"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<div
className={css.TikTokEmbedContainer}
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
/>
)}
</Box> </Box>
); );
}); });