import React from 'react'; import { defineMessages } from 'react-intl'; import { Drafty } from 'tinode-sdk'; import AudioPlayer from '../widgets/audio-player.jsx' import CallMessage from '../widgets/call-message.jsx' import CallStatus from '../widgets/call-status.jsx'; import InlineVideo from '../widgets/inline-video.jsx'; import LazyImage from '../widgets/lazy-image.jsx' import UploadingImage from '../widgets/uploading-image.jsx' import { BROKEN_IMAGE_SIZE, CLICKABLE_URL_SCHEMES, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO, REM_SIZE, VIDEO_THUMBNAIL_WIDTH } from '../config.js'; import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js'; import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js'; import { cancelablePromise, sanitizeUrl, sanitizeUrlForMime } from './utils.js'; const messages = defineMessages({ drafty_form: { id: 'drafty_form', defaultMessage: 'Form: ', description: 'Comment for form in Drafty' }, drafty_attachment: { id: 'drafty_attachment', defaultMessage: 'Attachment', description: 'Comment for attachment in Drafty' }, drafty_image: { id: 'drafty_image', defaultMessage: 'Picture', description: 'Comment for embedded images in Drafty' }, drafty_video: { id: 'drafty_video', defaultMessage: 'Video recording', description: 'Comment for videos embedded in Drafty' }, drafty_unknown: { id: 'drafty_unknown', defaultMessage: 'Unsupported', description: 'Unsupported entity in drafty' } }); // The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller. // 'this' must contain: // viewportWidth: // authorizeURL: // onImagePreview: // onVideoPreview: // onFormButtonClick: // onQuoteClick: export function fullFormatter(style, data, values, key, stack) { if (stack.includes('QQ')) { return quoteFormatter.call(this, style, data, values, key); } if (!style) { // Unformatted. return values; } let el = Drafty.tagName(style); let attr = Drafty.attrValue(style, data) || {}; attr.key = key; switch (style) { case 'AU': // Show audio player. if (attr.src) { attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio')); attr.duration = data.duration > 0 ? (data.duration | 0) : undefined; attr.preview = data.preview; attr.loading = 'lazy'; } el = AudioPlayer; // Audio element cannot have content. values = null; break; case 'BR': values = null; break; case 'EX': // Ignore. break; case 'HL': // Highlighted text. Assign class name. attr.className = 'highlight'; break; case 'HD': el = null; values = null; break; case 'IM': // Additional processing for images el = handleImageData.call(this, el, data, attr); // Image element cannot have content. values = null; break; case 'BN': // Button attr.onClick = this.onFormButtonClick; let inner = React.Children.map(values, (child) => { return typeof child == 'string' ? child : undefined; }); if (!inner || inner.length == 0) { inner = [attr.name] } // Get text which will be sent back when the button is clicked. attr['data-title'] = inner.join(''); break; case 'LN': if (attr) { // Ensure only safe URL schemes are clickable. attr.href = typeof attr.href == 'string' ? sanitizeUrl(attr.href, CLICKABLE_URL_SCHEMES) : ''; } break; case 'MN': // Mention attr.className = 'mention' if (data) { attr.className += ' ' + idToColorClass(data.val, false, true); } break; case 'FM': // Form attr.className = 'bot-form'; break; case 'RW': // Form element formatting is dependent on element content. break; case 'QQ': // Quote/citation. attr.className = 'reply-quote' attr.onClick = this.onQuoteClick; break; case 'VC': el = CallMessage; // Video call messages do not have content. values = null; if (data) { attr.callState = data.state; attr.incoming = data.incoming; attr.duration = data.duration; } break; case 'VD': // Additional processing for videos. el = handleVideoData.call(this, el, data, attr); // Video element cannot have content. values = null; break; default: if (!el) { // Unknown element. el = React.Fragment; attr = {key: key}; // Generate comment for unknown element. let body = values; if (!Array.isArray(values) || !values.join('').trim()) { body = [{this.formatMessage(messages.drafty_unknown)}]; } values = [extension, ' '].concat(body); } break; } if (!el) { return values; } return React.createElement(el, attr, values); } // Additional processing of image data. function handleImageData(el, data, attr) { if (!data) { attr.src = 'img/broken_image.png'; attr.style = { width: IMAGE_THUMBNAIL_DIM + 'px', height: IMAGE_THUMBNAIL_DIM + 'px', }; return el; } attr.className = 'inline-image'; const dim = fitImageSize(data.width, data.height, this.viewportWidth > 0 ? Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) : REM_SIZE * 34.5, REM_SIZE * 24, false) || {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE}; attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px', // Looks like a Chrome bug: broken image does not respect 'width' and 'height'. minWidth: dim.dstWidth + 'px', minHeight: dim.dstHeight + 'px' }; if (!Drafty.isProcessing(data)) { attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image')); attr.alt = data.name; if (attr.src) { if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) { // Allow previews for large enough images. attr.onClick = this.onImagePreview; attr.className += ' image-clickable'; } attr.loading = 'lazy'; } else { attr.src = null; } } else { // Use custom element instead of or