import classNames from 'classnames' import React from 'react' import type { Attachment, LocalMessage } from 'stream-chat' import { MessageErrorIcon, MessageText, type MessageContextValue, } from 'stream-chat-react' import { optimizeMessagingAttachmentUrl } from '../../utils/cdnImageUrl' import LinkAttachment from '../LinkAttachment' import { isPlayableMedia } from '../LinkAttachment/components/_shared/CardThumbnail' import { isLinkAttachment } from '../MediaMessage' import MessageAttachment from '../MessageAttachment' import type { BubbleGroupPosition } from '../MessageAttachment/types' import { MessageTail } from './MessageTail' type StreamMediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'file' export type AttachmentSegment = | { type: 'link'; attachments: Attachment[] } | { type: 'media'; kind: StreamMediaKind; attachments: Attachment[] } function trimToUndefined(value?: string | null): string | undefined { const trimmed = value?.trim() return trimmed ? trimmed : undefined } /** * Width ÷ height of the source media, from the dimensions Stream stores on the * attachment, or `undefined` when they are absent or unusable. * * These are what let a single video card know its shape on first paint. The * alternative — probing `loadedmetadata` — would fetch video bytes for every * card in a thread, against the deliberate `preload: 'none'`, and a poster is * not a substitute because an uploaded clip may not have one. * * Both fields are optional on Stream's `Attachment` and are absent on anything * sent before the composer began writing them, so every caller must still cope * with `undefined` rather than assuming a ratio (MES-1353). */ function aspectRatioFrom(attachment: Attachment): number | undefined { const { original_width: width, original_height: height } = attachment if (typeof width !== 'number' || typeof height !== 'number') return undefined if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined if (width <= 0 || height <= 0) return undefined return width / height } function normalizeLinkUrl(url: string): string { return url .trim() .replace(/^https?:\/\//i, '') .replace(/\/+$/, '') .toLowerCase() } export function linkCaptionDuplicatesAttachmentUrl( caption: string | undefined, attachmentUrl?: string ): boolean { if (!caption?.trim() || !attachmentUrl?.trim()) { return false } const normalizedCaption = normalizeLinkUrl(caption) const normalizedUrl = normalizeLinkUrl(attachmentUrl) if (normalizedCaption === normalizedUrl) { return true } if (!normalizedCaption.includes(normalizedUrl)) { return false } const remainder = normalizedCaption.split(normalizedUrl).join('') return !/[^\p{P}\p{Z}\p{C}]/u.test(remainder) } export function linkCardPropsFromStreamAttachment(a: Attachment) { const url = trimToUndefined(a.og_scrape_url) ?? trimToUndefined(a.title_link) const thumbnailUrl = trimToUndefined((a as { image_url?: string }).image_url) ?? trimToUndefined((a as { thumb_url?: string }).thumb_url) const sourceUrl = trimToUndefined(a.asset_url) const mimeType = trimToUndefined(a.mime_type) return { title: trimToUndefined((a as { title?: string }).title), description: trimToUndefined((a as { text?: string }).text), url, thumbnailUrl: optimizeMessagingAttachmentUrl(thumbnailUrl), // Playable clip embedded in a link preview — lets LinkAttachment render its // native video/audio player in the hero instead of a static thumbnail. sourceUrl, layout: thumbnailUrl || isPlayableMedia(mimeType, sourceUrl) ? 'featured' : 'classic', mimeType, // Dominant colour of the image, resolved by the sender at stage time and // persisted on the attachment (`stream-custom-data.ts`), so every viewer // paints an identical chin without re-extracting — visitors in // linktr.ee-profiles cannot call the extraction query at all. Absent on // messages sent before MES-1349, which render the fallback chin. accentColor: trimToUndefined(a.accent_color), // A persisted attachment is by definition fully scraped. status: 'ready', } as const } function getAttachmentMediaKind( attachment: Attachment ): StreamMediaKind | null { if (isLinkAttachment(attachment)) return null if (attachment.type === 'image') { const imageUrl = (attachment as { image_url?: string }).image_url if (imageUrl || attachment.asset_url) return 'image' return null } if (attachment.type === 'video' && attachment.asset_url) return 'video' if (attachment.type === 'audio' && attachment.asset_url) return 'audio' if (attachment.type === 'file' && attachment.asset_url) { if (attachment.mime_type === 'application/pdf') return 'pdf' if (attachment.mime_type?.startsWith('audio/')) return 'audio' return 'file' } return null } export function buildOrderedAttachmentSegments( attachments: Attachment[] | undefined ): AttachmentSegment[] { if (!attachments?.length) return [] const segments: AttachmentSegment[] = [] const pushOrMergeMedia = (kind: StreamMediaKind, attachment: Attachment) => { const last = segments[segments.length - 1] if (last?.type === 'media' && last.kind === kind) { last.attachments.push(attachment) return } segments.push({ type: 'media', kind, attachments: [attachment] }) } const pushOrMergeLink = (attachment: Attachment) => { const last = segments[segments.length - 1] if (last?.type === 'link') { last.attachments.push(attachment) return } segments.push({ type: 'link', attachments: [attachment] }) } for (const attachment of attachments) { if (isLinkAttachment(attachment)) { pushOrMergeLink(attachment) continue } const kind = getAttachmentMediaKind(attachment) if (!kind) continue pushOrMergeMedia(kind, attachment) } return segments } function countMediaSegmentBubbles(segments: AttachmentSegment[]): number { return segments.filter((segment) => segment.type === 'media').length } function bubblePositionInRun( index: number, total: number, streamPosition: BubbleGroupPosition ): BubbleGroupPosition { if (total <= 1) return streamPosition if (index === 0) return 'first' if (index === total - 1) return 'end' return 'middle' } function imageSrcFromAttachment(attachment: Attachment): string { return ( trimToUndefined((attachment as { image_url?: string }).image_url) ?? trimToUndefined(attachment.asset_url) ?? '' ) } function messageAttachmentNamespaceForKind(kind: StreamMediaKind) { switch (kind) { case 'image': return MessageAttachment.Image case 'video': return MessageAttachment.Video case 'audio': return MessageAttachment.Audio case 'pdf': return MessageAttachment.Pdf default: return MessageAttachment.File } } // Per-attachment render props for a single media kind. Both the single-item and // multi-item (cluster) paths shape each attachment the same way, so they share // this builder. function mediaItemForKind( kind: StreamMediaKind, attachment: Attachment ): Record { switch (kind) { case 'image': return { src: imageSrcFromAttachment(attachment), alt: trimToUndefined((attachment as { title?: string }).title), } case 'video': return { src: trimToUndefined(attachment.asset_url) ?? '', poster: optimizeMessagingAttachmentUrl(attachment.thumb_url), mimeType: trimToUndefined(attachment.mime_type), // Stream carries the source dimensions and the composer writes them // at send time, so a card knows its shape on first paint — no poster // needed and no `loadedmetadata` probe, which would mean fetching // video bytes for every card in the thread against the deliberate // `preload: 'none'`. Same trick the audio card uses for `duration` / // `waveform_data`. This builder serves both the single and clustered // paths, so the grid tiles get the ratio here too (MES-1353). naturalAspectRatio: aspectRatioFrom(attachment), } case 'audio': return { src: trimToUndefined(attachment.asset_url) ?? '', mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg', filename: trimToUndefined((attachment as { title?: string }).title), } case 'pdf': return { src: trimToUndefined(attachment.asset_url) ?? '', filename: trimToUndefined((attachment as { title?: string }).title), fileSize: (attachment as { file_size?: number }).file_size, } case 'file': return { src: trimToUndefined(attachment.asset_url) ?? '', filename: trimToUndefined((attachment as { title?: string }).title), fileSize: (attachment as { file_size?: number }).file_size, mimeType: trimToUndefined(attachment.mime_type) ?? 'application/octet-stream', } } } function buildMediaClusterRenderProps( kind: StreamMediaKind, attachments: Attachment[] ): Record { if (attachments.length > 1) { return { items: attachments.map((attachment) => mediaItemForKind(kind, attachment) ), } } const attachment = attachments[0] if (!attachment) return {} return mediaItemForKind(kind, attachment) } function MediaClusterBubble({ groupPosition, isMyMessage, segment, }: { groupPosition: BubbleGroupPosition isMyMessage: boolean segment: Extract }) { const TypeNamespace = messageAttachmentNamespaceForKind(segment.kind) const props = { ...buildMediaClusterRenderProps(segment.kind, segment.attachments), groupPosition, } if (isMyMessage) { return } return } export interface StreamAttachmentMessageProps { groupPosition: BubbleGroupPosition isMyMessage: boolean message: LocalMessage renderText?: MessageContextValue['renderText'] } const StreamAttachmentMessage: React.FC = ({ groupPosition, isMyMessage, message, renderText, }) => { const segments = buildOrderedAttachmentSegments(message.attachments) if (segments.length === 0) return null const totalMediaBubbles = countMediaSegmentBubbles(segments) const rawCaption = message.text?.trim() const linkSegment = segments.find((segment) => segment.type === 'link') const primaryLinkUrl = linkSegment?.type === 'link' ? linkCardPropsFromStreamAttachment(linkSegment.attachments[0] ?? {}).url : undefined const caption = rawCaption && linkCaptionDuplicatesAttachmentUrl(rawCaption, primaryLinkUrl) ? undefined : rawCaption let mediaBubbleIndex = 0 const rows: React.ReactNode[] = [] // A link card paints its own accent-coloured tail only when it is the very // last thing this message renders. A caption bubble follows when there is // one, and that bubble owns the tail (rendered below via `MessageTail`); and a // message mid-run has its tail on a later message. Anything else is a mid-run // card, which must stay tail-less. const linkTailPosition: BubbleGroupPosition = caption ? 'middle' : groupPosition // The trailing caption bubble carries the tail on single / last-in-run // messages, matching the in-channel text bubbles. const captionShowsTail = groupPosition === 'single' || groupPosition === 'end' const pushLinkRow = ( attachment: Attachment, key: string, isFinalRow: boolean ) => { const Card = isMyMessage ? LinkAttachment.Sent : LinkAttachment.Received rows.push( ) } const pushMediaCluster = ( segment: Extract, key: string ) => { const position = bubblePositionInRun( mediaBubbleIndex, totalMediaBubbles, groupPosition ) mediaBubbleIndex += 1 rows.push( ) } segments.forEach((segment, segmentIndex) => { const isFinalSegment = segmentIndex === segments.length - 1 if (segment.type === 'link') { segment.attachments.forEach((attachment, attachmentIndex) => { pushLinkRow( attachment, `link-${segmentIndex}-${attachmentIndex}`, isFinalSegment && attachmentIndex === segment.attachments.length - 1 ) }) return } pushMediaCluster(segment, `media-${segmentIndex}`) }) return (
{rows} {!caption && }
{caption ? (
{captionShowsTail && ( )}
) : null}
) } export default StreamAttachmentMessage