import React from 'react'; import type { Attachment } from '@backspace/shared'; import { useUIStore } from '../../stores/uiStore'; import { useTransferStore } from '../../stores/transferStore'; import { Tooltip } from '../ui/Tooltip'; interface AttachmentRendererProps { attachment: Attachment; } function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(1)} MB`; } /** * Resolves the displayable URL for an attachment. Same logic used by inline * ``/``/`` rendering and the file-card download button — * exported so right-click menus can use it without duplicating the rule. */ export function attUrlOf(filename: string): string { if (filename.startsWith('http') || filename.startsWith('/')) return filename; return `/api/uploads/${filename}`; } export function AttachmentRenderer({ attachment }: AttachmentRendererProps) { const openImagePreview = useUIStore((s) => s.openImagePreview); const startDownload = useTransferStore((s) => s.startDownload); const attUrl = attUrlOf(attachment.filename); const thumbUrl = attachment.thumbnailFilename ? attachment.thumbnailFilename.startsWith('http') || attachment.thumbnailFilename.startsWith('/') ? attachment.thumbnailFilename : `/api/uploads/${attachment.thumbnailFilename}` : null; const { mimetype, originalName, size } = attachment; // Federation status — build tooltip text const federationTooltip = (() => { if (!attachment.federationStatus) return null; if (attachment.federationStatus === 'remote') { let senderName = 'the sender'; if (attachment.federationMeta) { try { const meta = JSON.parse(attachment.federationMeta); if (meta.sourceUsername) senderName = meta.sourceUsername; } catch { /* ignore */ } } return { text: `Hosted on ${senderName}'s instance. Download to keep a local copy.`, type: 'remote' as const }; } if (attachment.federationStatus === 'remote_partial') { let text = 'Some recipients cannot cache this file locally.'; if (attachment.federationMeta) { try { const meta: Array<{ username: string; limit: number }> = JSON.parse(attachment.federationMeta); if (meta.length > 0) { const parts = meta.map(u => { const limitMb = Math.round(u.limit / (1024 * 1024)); return `${u.username}'s instance (limit: ${limitMb} MB)`; }); text = `File couldn't be cached on ${parts.join(' and ')}. They can still view it from yours.`; } } catch { /* ignore */ } } return { text, type: 'remote_partial' as const }; } return null; })(); // Inline badge — sits next to file size or below media, never absolute-positioned const federationInlineBadge = federationTooltip ? ( {federationTooltip.type === 'remote' ? : <>> } ) : null; if (mimetype.startsWith('image/')) { const { width, height } = attachment; return ( openImagePreview(attUrl)} loading="lazy" /> {federationInlineBadge && {federationInlineBadge}} ); } if (mimetype.startsWith('video/')) { const { width, height } = attachment; const hasDimensions = width && height; return ( Your browser does not support video playback. {federationInlineBadge && {federationInlineBadge}} ); } if (mimetype.startsWith('audio/')) { return ( {originalName} {formatFileSize(size)} {federationInlineBadge} Your browser does not support audio playback. ); } return ( { void startDownload(attUrl, { filename: originalName, size, mimetype, tray: true, }); }} className="mt-1 max-w-[400px] flex items-center gap-3 p-4 bg-surface-channel/50 rounded-lg border border-border-hard hover:bg-interactive-hover transition-all group/att text-left w-full" > {originalName} {formatFileSize(size)} {federationInlineBadge} ); }
{originalName}
{formatFileSize(size)}