From 71ad5f5fc6ef084644c8d00cf050ab24933fc6f3 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 25 Jan 2025 14:14:33 +0300 Subject: [PATCH 01/55] Update attachments over realtime and during drafts setup --- src/redux/action-creators.js | 9 +++++++++ src/redux/action-types.js | 2 ++ src/redux/middlewares.js | 2 ++ src/redux/reducers.js | 17 ++++++++--------- src/services/api.js | 4 ++++ src/services/drafts.js | 3 ++- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index c31b83e9c..99cf36eaa 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -1262,6 +1262,15 @@ export function getAttachmentsStats() { }; } +export function getAttachmentInfo(attId) { + return { + type: ActionTypes.GET_ATTACHMENT_INFO, + apiRequest: Api.getAttachmentInfo, + nonAuthRequest: true, + payload: { attId }, + }; +} + export function sanitizeMedia() { return { type: ActionTypes.SANITIZE_MEDIA, diff --git a/src/redux/action-types.js b/src/redux/action-types.js index 90d88deed..d823cbdd6 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -91,6 +91,7 @@ export const REALTIME_COMMENT_DESTROY = 'REALTIME_COMMENT_DESTROY'; export const REALTIME_LIKE_NEW = 'REALTIME_LIKE_NEW'; export const REALTIME_LIKE_REMOVE = 'REALTIME_LIKE_REMOVE'; export const REALTIME_GLOBAL_USER_UPDATE = 'REALTIME_GLOBAL_USER_UPDATE'; +export const REALTIME_ATTACHMENT_UPDATE = 'REALTIME_ATTACHMENT_UPDATE'; export const UNSUBSCRIBE_FROM_GROUP = 'UNSUBSCRIBE_FROM_GROUP'; export const MAKE_GROUP_ADMIN = 'MAKE_GROUP_ADMIN'; export const UNADMIN_GROUP_ADMIN = 'UNADMIN_GROUP_ADMIN'; @@ -163,6 +164,7 @@ export const OPEN_SIDEBAR = 'OPEN_SIDEBAR'; export const SET_SUBMIT_MODE = 'SET_SUBMIT_MODE'; export const LEAVE_DIRECT = 'LEAVE_DIRECT'; export const GET_ATTACHMENTS_STATS = 'GET_ATTACHMENTS_STATS'; +export const GET_ATTACHMENT_INFO = 'GET_ATTACHMENT_INFO'; export const SANITIZE_MEDIA = 'SANITIZE_MEDIA'; export const GET_COMMENT_BY_NUMBER = 'GET_COMMENT_BY_NUMBER'; export const GET_GROUP_BLOCKED_USERS = 'GET_GROUP_BLOCKED_USERS'; diff --git a/src/redux/middlewares.js b/src/redux/middlewares.js index dcb28e99f..bf4061926 100644 --- a/src/redux/middlewares.js +++ b/src/redux/middlewares.js @@ -733,6 +733,8 @@ const bindHandlers = (store) => ({ }), 'global:user:update': (data) => store.dispatch({ type: ActionTypes.REALTIME_GLOBAL_USER_UPDATE, user: data.user }), + 'attachment:update': (data) => + store.dispatch({ ...data, type: ActionTypes.REALTIME_ATTACHMENT_UPDATE }), }); export const realtimeMiddleware = (store) => { diff --git a/src/redux/reducers.js b/src/redux/reducers.js index e86701f03..027c88d90 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -657,34 +657,33 @@ export const postHideStatuses = asyncStatesMap( export function attachments(state = {}, action) { if (ActionHelpers.isFeedResponse(action)) { - return mergeByIds(state, action.payload.attachments, { insert: true, update: true }); + return mergeByIds(state, action.payload.attachments, { update: true }); } switch (action.type) { case response(ActionTypes.GET_SINGLE_POST): case response(ActionTypes.COMPLETE_POST_COMMENTS): case response(ActionTypes.CREATE_POST): { - return mergeByIds(state, action.payload.attachments); + return mergeByIds(state, action.payload.attachments, { update: true }); } case ActionTypes.REALTIME_POST_NEW: case ActionTypes.REALTIME_POST_UPDATE: { - return mergeByIds(state, action.attachments); + return mergeByIds(state, action.attachments, { update: true }); } case ActionTypes.REALTIME_COMMENT_NEW: case ActionTypes.REALTIME_LIKE_NEW: { if (action.post && action.post.attachments) { - return mergeByIds(state, action.post.attachments); + return mergeByIds(state, action.post.attachments, { update: true }); } return state; } + case ActionTypes.REALTIME_ATTACHMENT_UPDATE: { + return mergeByIds(state, [action.attachments], { update: true }); + } case response(ActionTypes.CREATE_ATTACHMENT): + case response(ActionTypes.GET_ATTACHMENT_INFO): case ActionTypes.SET_ATTACHMENT: case ActionTypes.ADD_ATTACHMENT_RESPONSE: { const attObj = action.payload.attachments; - // Attachment objects don't change over time, so we don't need to update - // them. - if (state[attObj.id]) { - return state; - } return { ...state, [attObj.id]: attObj, diff --git a/src/services/api.js b/src/services/api.js index 56e6326f3..79ca8ce4d 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -780,6 +780,10 @@ export function getAttachmentsStats() { return fetch(`${apiPrefix}/attachments/my/stats`, getRequestOptions()); } +export function getAttachmentInfo({ attId }) { + return fetch(`${apiPrefix}/attachments/${attId}`, getRequestOptions()); +} + export function sanitizeMedia() { return fetch(`${apiPrefix}/attachments/my/sanitize`, postRequestOptions()); } diff --git a/src/services/drafts.js b/src/services/drafts.js index f568185d0..4f0657ecb 100644 --- a/src/services/drafts.js +++ b/src/services/drafts.js @@ -2,7 +2,7 @@ /* global CONFIG */ import storage from 'local-storage-fallback'; import { isEqual, omit } from 'lodash-es'; -import { setAttachment } from '../redux/action-creators'; +import { getAttachmentInfo, setAttachment } from '../redux/action-creators'; import { setDelayedAction } from './drafts-throttling'; import { EventEmitter } from './drafts-events'; @@ -208,6 +208,7 @@ export function initializeDrafts(store) { // Put found files to the redux store for (const file of allFiles.values()) { store.dispatch(setAttachment(file)); + store.dispatch(getAttachmentInfo(file.id)); } // Subscribe to the storage events From 5a92704e16af714b798dc8d68b8a6798075cf096 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 25 Jan 2025 14:24:47 +0300 Subject: [PATCH 02/55] Add "processing..." text for files in progress --- src/components/post/post-attachment-general.jsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/post/post-attachment-general.jsx b/src/components/post/post-attachment-general.jsx index 7150b41e8..307dc606e 100644 --- a/src/components/post/post-attachment-general.jsx +++ b/src/components/post/post-attachment-general.jsx @@ -10,10 +10,21 @@ class GeneralAttachment extends PureComponent { this.props.removeAttachment(this.props.id); }; + handleClick = (e) => { + if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { + return; + } + if (this.props.inProgress) { + e.preventDefault(); + alert('This file is still being processed'); + } + }; + render() { const { props } = this; + const { inProgress = false } = props; const formattedFileSize = formatFileSize(props.fileSize); - const nameAndSize = `${props.fileName} (${formattedFileSize})`; + const nameAndSize = `${props.fileName} (${inProgress ? 'processing...' : formattedFileSize})`; return (
From c3f4222210e85ef92f9a8b51178cbab059baea27 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 25 Jan 2025 14:29:49 +0300 Subject: [PATCH 03/55] Detect video file by URL extension --- src/components/post/post-attachments.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/post/post-attachments.jsx b/src/components/post/post-attachments.jsx index e69e077bb..90ab3542c 100644 --- a/src/components/post/post-attachments.jsx +++ b/src/components/post/post-attachments.jsx @@ -22,6 +22,12 @@ const supportedVideoTypes = Object.entries(videoTypes) video = null; const looksLikeAVideoFile = (attachment) => { + if (attachment.inProgress) { + return false; + } + if (attachment.url.endsWith('.mp4')) { + return true; + } const lowercaseFileName = attachment.fileName.toLowerCase(); for (const extension of supportedVideoTypes) { From a665c5a15e2e4942f79a8ac8b7ee15e410f5714b Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 25 Jan 2025 14:34:19 +0300 Subject: [PATCH 04/55] Update test snapshot --- .../post-attachments.test.jsx.snap | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/test/jest/__snapshots__/post-attachments.test.jsx.snap b/test/jest/__snapshots__/post-attachments.test.jsx.snap index 19a6392ab..3850396fd 100644 --- a/test/jest/__snapshots__/post-attachments.test.jsx.snap +++ b/test/jest/__snapshots__/post-attachments.test.jsx.snap @@ -118,38 +118,63 @@ exports[`PostAttachments > Displays all post attachment types 1`] = `
+
Date: Sat, 25 Jan 2025 19:46:34 +0300 Subject: [PATCH 05/55] Fetch draft attachments details from the server --- src/components/post/post-attachments.jsx | 2 +- src/services/drafts.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/post/post-attachments.jsx b/src/components/post/post-attachments.jsx index 90ab3542c..dad77b8b2 100644 --- a/src/components/post/post-attachments.jsx +++ b/src/components/post/post-attachments.jsx @@ -41,7 +41,7 @@ const looksLikeAVideoFile = (attachment) => { export default function PostAttachments(props) { const attachments = useSelector( - (state) => (props.attachmentIds || []).map((id) => state.attachments[id]), + (state) => (props.attachmentIds || []).map((id) => state.attachments[id]).filter(Boolean), shallowEqual, ); diff --git a/src/services/drafts.js b/src/services/drafts.js index 4f0657ecb..184c9c8a2 100644 --- a/src/services/drafts.js +++ b/src/services/drafts.js @@ -207,7 +207,6 @@ export function initializeDrafts(store) { // Put found files to the redux store for (const file of allFiles.values()) { - store.dispatch(setAttachment(file)); store.dispatch(getAttachmentInfo(file.id)); } From 6d963aeb91d4d81bb4d29ca2f1937d96d8f3aaba Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Jan 2025 12:10:43 +0300 Subject: [PATCH 06/55] Use new API for image attachments --- .../post/post-attachment-image-container.jsx | 12 +-- src/components/post/post-attachment-image.jsx | 86 +++++++++---------- src/services/api-version.js | 2 +- src/services/api.js | 9 ++ 4 files changed, 59 insertions(+), 50 deletions(-) diff --git a/src/components/post/post-attachment-image-container.jsx b/src/components/post/post-attachment-image-container.jsx index 227d33db5..9db955fce 100644 --- a/src/components/post/post-attachment-image-container.jsx +++ b/src/components/post/post-attachment-image-container.jsx @@ -6,7 +6,8 @@ import { faChevronCircleRight } from '@fortawesome/free-solid-svg-icons'; import { Icon } from '../fontawesome-icons'; import { lazyComponent } from '../lazy-component'; import { openLightbox } from '../../services/lightbox'; -import ImageAttachment from './post-attachment-image'; +import { attachmentPreviewUrl } from '../../services/api'; +import ImageAttachment, { previewSizes } from './post-attachment-image'; const bordersSize = 4; const spaceSize = 8; @@ -38,7 +39,7 @@ export default class ImageAttachmentsContainer extends Component { getItemWidths() { return this.props.attachments - .map(({ imageSizes: { t, o } }) => (t ? t.w : o ? o.w : 0)) + .map((att) => previewSizes(att).width) .map((w) => w + bordersSize + spaceSize); } @@ -72,11 +73,10 @@ export default class ImageAttachmentsContainer extends Component { getPswpItems() { return this.props.attachments.map((a) => ({ - src: a.url, - width: a.imageSizes?.o?.w ?? 1, - height: a.imageSizes?.o?.h ?? 1, + src: attachmentPreviewUrl(a.id, 'image'), + width: a.previewWidth ?? a.width, + height: a.previewHeight ?? a.height, pid: this.getPictureId(a), - autoSize: !a.imageSizes?.o?.w, })); } diff --git a/src/components/post/post-attachment-image.jsx b/src/components/post/post-attachment-image.jsx index 25d44ee9c..80d8436c0 100644 --- a/src/components/post/post-attachment-image.jsx +++ b/src/components/post/post-attachment-image.jsx @@ -4,9 +4,13 @@ import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { formatFileSize } from '../../utils'; import { Icon } from '../fontawesome-icons'; +import { attachmentPreviewUrl } from '../../services/api'; const NSFW_PREVIEW_AREA = 20; +const previewMaxWidth = 525; +const previewMaxHeight = 175; + class PostAttachmentImage extends PureComponent { canvasRef = createRef(null); @@ -19,57 +23,39 @@ class PostAttachmentImage extends PureComponent { if (!nsfwCanvas) { return; } + const { width, height } = previewSizes(this.props); const ctx = nsfwCanvas.getContext('2d'); ctx.fillStyle = '#cccccc'; ctx.fillRect(0, 0, nsfwCanvas.width, nsfwCanvas.height); const img = new Image(); img.onload = () => nsfwCanvas.isConnected && ctx.drawImage(img, 0, 0, nsfwCanvas.width, nsfwCanvas.height); - img.src = this.props.imageSizes.t?.url ?? this.props.thumbnailUrl; + img.src = attachmentPreviewUrl(this.props.id, 'image', width, height); } render() { const { props } = this; const formattedFileSize = formatFileSize(props.fileSize); - const formattedImageSize = props.imageSizes.o - ? `, ${props.imageSizes.o.w}×${props.imageSizes.o.h}px` - : ''; + const formattedImageSize = `, ${props.width}×${props.height}px`; const nameAndSize = `${props.fileName} (${formattedFileSize}${formattedImageSize})`; const alt = `Image attachment ${props.fileName}`; - let srcSet; - if (props.imageSizes.t2 && props.imageSizes.t2.url) { - srcSet = `${props.imageSizes.t2.url} 2x`; - } else if ( - props.imageSizes.o && - props.imageSizes.t && - props.imageSizes.o.w <= props.imageSizes.t.w * 2 - ) { - srcSet = `${props.imageSizes.o.url || props.url} 2x`; - } + const { width, height } = previewSizes(this.props); const imageAttributes = { - src: (props.imageSizes.t && props.imageSizes.t.url) || props.thumbnailUrl, - srcSet, + src: attachmentPreviewUrl(props.id, 'image', width, height), + srcSet: `${attachmentPreviewUrl(props.id, 'image', width * 2, height * 2)} 2x`, alt, id: props.pictureId, loading: 'lazy', - width: props.imageSizes.t - ? props.imageSizes.t.w - : props.imageSizes.o - ? props.imageSizes.o.w - : undefined, - height: props.imageSizes.t - ? props.imageSizes.t.h - : props.imageSizes.o - ? props.imageSizes.o.h - : undefined, + width, + height, }; - const area = imageAttributes.width * imageAttributes.height; - const canvasWidth = Math.round(imageAttributes.width * Math.sqrt(NSFW_PREVIEW_AREA / area)); - const canvasHeight = Math.round(imageAttributes.height * Math.sqrt(NSFW_PREVIEW_AREA / area)); + const area = width * height; + const canvasWidth = Math.round(width * Math.sqrt(NSFW_PREVIEW_AREA / area)); + const canvasHeight = Math.round(height * Math.sqrt(NSFW_PREVIEW_AREA / area)); return (
- {props.thumbnailUrl ? ( - <> - {props.isNSFW && ( - - )} - - - ) : ( - props.id + {props.isNSFW && ( + )} + {props.isEditing && ( @@ -115,3 +95,23 @@ class PostAttachmentImage extends PureComponent { } export default PostAttachmentImage; + +export function previewSizes(att) { + return fitIntoBox( + att.previewWidth ?? att.width, + att.previewHeight ?? att.height, + previewMaxWidth, + previewMaxHeight, + ); +} + +function fitIntoBox(width, height, boxWidth, boxHeight) { + const wRatio = width / boxWidth; + const hRatio = height / boxHeight; + + if (wRatio > hRatio) { + return { width: boxWidth, height: Math.round(height / wRatio) }; + } + + return { width: Math.round(width / hRatio), height: boxHeight }; +} diff --git a/src/services/api-version.js b/src/services/api-version.js index cdedc9291..aa8531f9f 100644 --- a/src/services/api-version.js +++ b/src/services/api-version.js @@ -1 +1 @@ -export const apiVersion = 3; +export const apiVersion = 4; diff --git a/src/services/api.js b/src/services/api.js index 79ca8ce4d..40ff53a5d 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -784,6 +784,15 @@ export function getAttachmentInfo({ attId }) { return fetch(`${apiPrefix}/attachments/${attId}`, getRequestOptions()); } +export function attachmentPreviewUrl(attId, type, width = null, height = null) { + const url = new URL(`${apiPrefix}/attachments/${attId}/${type}?redirect`); + if (width && height) { + url.searchParams.set('width', width); + url.searchParams.set('height', height); + } + return url.toString(); +} + export function sanitizeMedia() { return fetch(`${apiPrefix}/attachments/my/sanitize`, postRequestOptions()); } From d818a4cfb37e52111f8e32c1edb23dabc599e755 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Jan 2025 12:11:47 +0300 Subject: [PATCH 07/55] Use new API for audio and general attachments --- src/components/post/post-attachment-audio.jsx | 8 +++++++- src/components/post/post-attachment-general.jsx | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/post/post-attachment-audio.jsx b/src/components/post/post-attachment-audio.jsx index 25d1d491c..b2b2f0415 100644 --- a/src/components/post/post-attachment-audio.jsx +++ b/src/components/post/post-attachment-audio.jsx @@ -4,6 +4,7 @@ import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { formatFileSize } from '../../utils'; import { Icon } from '../fontawesome-icons'; +import { attachmentPreviewUrl } from '../../services/api'; class AudioAttachment extends PureComponent { handleClickOnRemoveAttachment = () => { @@ -26,7 +27,12 @@ class AudioAttachment extends PureComponent { return (
-
diff --git a/src/components/post/post-attachment-general.jsx b/src/components/post/post-attachment-general.jsx index 307dc606e..8aeb806ef 100644 --- a/src/components/post/post-attachment-general.jsx +++ b/src/components/post/post-attachment-general.jsx @@ -14,7 +14,7 @@ class GeneralAttachment extends PureComponent { if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } - if (this.props.inProgress) { + if (this.props.meta?.inProgress) { e.preventDefault(); alert('This file is still being processed'); } @@ -22,7 +22,7 @@ class GeneralAttachment extends PureComponent { render() { const { props } = this; - const { inProgress = false } = props; + const { inProgress = false } = props.meta ?? {}; const formattedFileSize = formatFileSize(props.fileSize); const nameAndSize = `${props.fileName} (${inProgress ? 'processing...' : formattedFileSize})`; From 6e5f96f0f3892058ea70a4ecfa7855b740c23c34 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sun, 26 Jan 2025 18:01:09 +0300 Subject: [PATCH 08/55] Show video attachments as video --- .../post/post-attachment-geometry.js | 27 ++++ .../post/post-attachment-image-container.jsx | 5 +- src/components/post/post-attachment-image.jsx | 28 +--- .../post/post-attachment-like-a-video.jsx | 94 ++++++++++++++ src/components/post/post-attachment-video.jsx | 122 +++++++----------- src/components/post/post-attachments.jsx | 29 ++++- styles/shared/attachments.scss | 8 +- styles/shared/media-viewer.scss | 5 - 8 files changed, 206 insertions(+), 112 deletions(-) create mode 100644 src/components/post/post-attachment-geometry.js create mode 100644 src/components/post/post-attachment-like-a-video.jsx diff --git a/src/components/post/post-attachment-geometry.js b/src/components/post/post-attachment-geometry.js new file mode 100644 index 000000000..be2076e01 --- /dev/null +++ b/src/components/post/post-attachment-geometry.js @@ -0,0 +1,27 @@ +const thumbnailMaxWidth = 525; +const thumbnailMaxHeight = 175; + +const videoMaxWidth = 500; +const videoMaxHeight = 400; + +export function thumbnailSize(att) { + return fitIntoBox(att, thumbnailMaxWidth, thumbnailMaxHeight); +} + +export function videoSize(att) { + return fitIntoBox(att, videoMaxWidth, videoMaxHeight); +} + +function fitIntoBox(att, boxWidth, boxHeight) { + const [width, height] = [att.previewWidth ?? att.width, att.previewHeight ?? att.height]; + boxWidth = Math.min(boxWidth, width); + boxHeight = Math.min(boxHeight, height); + const wRatio = width / boxWidth; + const hRatio = height / boxHeight; + + if (wRatio > hRatio) { + return { width: boxWidth, height: Math.round(height / wRatio) }; + } + + return { width: Math.round(width / hRatio), height: boxHeight }; +} diff --git a/src/components/post/post-attachment-image-container.jsx b/src/components/post/post-attachment-image-container.jsx index 9db955fce..a14fc1d5a 100644 --- a/src/components/post/post-attachment-image-container.jsx +++ b/src/components/post/post-attachment-image-container.jsx @@ -7,7 +7,8 @@ import { Icon } from '../fontawesome-icons'; import { lazyComponent } from '../lazy-component'; import { openLightbox } from '../../services/lightbox'; import { attachmentPreviewUrl } from '../../services/api'; -import ImageAttachment, { previewSizes } from './post-attachment-image'; +import ImageAttachment from './post-attachment-image'; +import { thumbnailSize } from './post-attachment-geometry'; const bordersSize = 4; const spaceSize = 8; @@ -39,7 +40,7 @@ export default class ImageAttachmentsContainer extends Component { getItemWidths() { return this.props.attachments - .map((att) => previewSizes(att).width) + .map((att) => thumbnailSize(att).width) .map((w) => w + bordersSize + spaceSize); } diff --git a/src/components/post/post-attachment-image.jsx b/src/components/post/post-attachment-image.jsx index 80d8436c0..2120aa802 100644 --- a/src/components/post/post-attachment-image.jsx +++ b/src/components/post/post-attachment-image.jsx @@ -5,12 +5,10 @@ import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { formatFileSize } from '../../utils'; import { Icon } from '../fontawesome-icons'; import { attachmentPreviewUrl } from '../../services/api'; +import { thumbnailSize } from './post-attachment-geometry'; const NSFW_PREVIEW_AREA = 20; -const previewMaxWidth = 525; -const previewMaxHeight = 175; - class PostAttachmentImage extends PureComponent { canvasRef = createRef(null); @@ -23,7 +21,7 @@ class PostAttachmentImage extends PureComponent { if (!nsfwCanvas) { return; } - const { width, height } = previewSizes(this.props); + const { width, height } = thumbnailSize(this.props); const ctx = nsfwCanvas.getContext('2d'); ctx.fillStyle = '#cccccc'; ctx.fillRect(0, 0, nsfwCanvas.width, nsfwCanvas.height); @@ -41,7 +39,7 @@ class PostAttachmentImage extends PureComponent { const nameAndSize = `${props.fileName} (${formattedFileSize}${formattedImageSize})`; const alt = `Image attachment ${props.fileName}`; - const { width, height } = previewSizes(this.props); + const { width, height } = thumbnailSize(this.props); const imageAttributes = { src: attachmentPreviewUrl(props.id, 'image', width, height), @@ -95,23 +93,3 @@ class PostAttachmentImage extends PureComponent { } export default PostAttachmentImage; - -export function previewSizes(att) { - return fitIntoBox( - att.previewWidth ?? att.width, - att.previewHeight ?? att.height, - previewMaxWidth, - previewMaxHeight, - ); -} - -function fitIntoBox(width, height, boxWidth, boxHeight) { - const wRatio = width / boxWidth; - const hRatio = height / boxHeight; - - if (wRatio > hRatio) { - return { width: boxWidth, height: Math.round(height / wRatio) }; - } - - return { width: Math.round(width / hRatio), height: boxHeight }; -} diff --git a/src/components/post/post-attachment-like-a-video.jsx b/src/components/post/post-attachment-like-a-video.jsx new file mode 100644 index 000000000..e42356855 --- /dev/null +++ b/src/components/post/post-attachment-like-a-video.jsx @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from 'react'; +import { faFileVideo, faPlayCircle } from '@fortawesome/free-regular-svg-icons'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; + +import { useEvent } from 'react-use-event-hook'; +import { formatFileSize } from '../../utils'; +import { ButtonLink } from '../button-link'; +import { Icon } from '../fontawesome-icons'; +import { attachmentPreviewUrl } from '../../services/api'; + +export default function LikeAVideoAttachment({ + id, + fileName, + fileSize, + removeAttachment, + isEditing, +}) { + const url = attachmentPreviewUrl(id, 'original'); + const [isOpen, setIsOpen] = useState(false); + + const handleClickOnRemoveAttachment = useEvent(() => removeAttachment(id)); + const toggleOpen = useEvent(() => setIsOpen(true)); + + const formattedFileSize = formatFileSize(fileSize); + const title = `${fileName} (${formattedFileSize})`; + + const videoRef = useRef(null); + + // Prevent video from playing infinitely (we has this situation once and don't + // want it to happen again) + useEffect(() => { + if (!isOpen || !videoRef.current) { + return; + } + const videoEl = videoRef.current; + + // By default, the video playback should be paused after 5 minutes + let maxPlayTime = 300 * 1000; + let playTimer = 0; + const onPlay = () => { + clearTimeout(playTimer); + playTimer = setTimeout(() => videoEl.pause(), maxPlayTime); + }; + const onPause = () => clearTimeout(playTimer); + const onDurationChange = () => { + // Video in playback mode should not be longer than 10 times of the video duration + maxPlayTime = videoEl.duration * 10 * 1000; + }; + const abortController = new AbortController(); + const { signal } = abortController; + + videoEl.addEventListener('durationchange', onDurationChange, { once: true, signal }); + videoEl.addEventListener('play', onPlay, { signal }); + videoEl.addEventListener('pause', onPause, { signal }); + signal.addEventListener('abort', onPause); + return () => abortController.abort(); + }, [isOpen]); + + return ( + + ); +} diff --git a/src/components/post/post-attachment-video.jsx b/src/components/post/post-attachment-video.jsx index 9c8557a4b..70e22ac79 100644 --- a/src/components/post/post-attachment-video.jsx +++ b/src/components/post/post-attachment-video.jsx @@ -1,93 +1,69 @@ -import { useEffect, useRef, useState } from 'react'; -import { faFileVideo, faPlayCircle } from '@fortawesome/free-regular-svg-icons'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; - import { useEvent } from 'react-use-event-hook'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { useEffect, useRef } from 'react'; +import { attachmentPreviewUrl } from '../../services/api'; import { formatFileSize } from '../../utils'; -import { ButtonLink } from '../button-link'; import { Icon } from '../fontawesome-icons'; +import { useMediaQuery } from '../hooks/media-query'; +import { videoSize } from './post-attachment-geometry'; -export default function VideoAttachment({ - id, - url, - fileName, - fileSize, - removeAttachment, - isEditing, -}) { - const [isOpen, setIsOpen] = useState(false); +export function VideoAttachment({ isEditing, removeAttachment, ...att }) { + const handleClickOnRemoveAttachment = useEvent(() => removeAttachment(att.id)); + const title = `Video attachment ${att.fileName} (${formatFileSize(att.fileSize)})`; + const { width, height } = videoSize(att); + const hiDpi = useMediaQuery('(min-resolution: 1.5x)') ? 2 : 1; - const handleClickOnRemoveAttachment = useEvent(() => removeAttachment(id)); - const toggleOpen = useEvent(() => setIsOpen(true)); + const videoUrl = attachmentPreviewUrl(att.id, 'video', hiDpi * width, hiDpi * height); - const formattedFileSize = formatFileSize(fileSize); - const title = `${fileName} (${formattedFileSize})`; + const maxVideoUrl = attachmentPreviewUrl(att.id, 'video'); const videoRef = useRef(null); - // Prevent video from playing infinitely (we has this situation once and don't - // want it to happen again) useEffect(() => { - if (!isOpen || !videoRef.current) { + const el = videoRef.current; + if (!el) { return; } - const videoEl = videoRef.current; - - // By default, the video playback should be paused after 5 minutes - let maxPlayTime = 300 * 1000; - let playTimer = 0; - const onPlay = () => { - clearTimeout(playTimer); - playTimer = setTimeout(() => videoEl.pause(), maxPlayTime); - }; - const onPause = () => clearTimeout(playTimer); - const onDurationChange = () => { - // Video in playback mode should not be longer than 10 times of the video duration - maxPlayTime = videoEl.duration * 10 * 1000; + const h = () => { + const { paused, currentTime } = el; + if (document.fullscreenElement) { + el.src = maxVideoUrl; + } else { + el.src = videoUrl; + } + el.load(); + el.currentTime = currentTime; + if (!paused) { + el.play(); + } }; - const abortController = new AbortController(); - const { signal } = abortController; - videoEl.addEventListener('durationchange', onDurationChange, { once: true, signal }); - videoEl.addEventListener('play', onPlay, { signal }); - videoEl.addEventListener('pause', onPause, { signal }); - signal.addEventListener('abort', onPause); - return () => abortController.abort(); - }, [isOpen]); + el.addEventListener('fullscreenchange', h); + return () => el.removeEventListener('fullscreenchange', h); + }, [maxVideoUrl, videoUrl]); return ( -
- {isOpen ? ( -
- -
- ) : ( - - - +
+
); } diff --git a/src/components/post/post-attachments.jsx b/src/components/post/post-attachments.jsx index dad77b8b2..5785898fb 100644 --- a/src/components/post/post-attachments.jsx +++ b/src/components/post/post-attachments.jsx @@ -4,7 +4,8 @@ import ErrorBoundary from '../error-boundary'; import ImageAttachmentsContainer from './post-attachment-image-container'; import AudioAttachment from './post-attachment-audio'; import GeneralAttachment from './post-attachment-general'; -import VideoAttachment from './post-attachment-video'; +import LikeAVideoAttachment from './post-attachment-like-a-video'; +import { VideoAttachment } from './post-attachment-video'; const videoTypes = { mov: 'video/quicktime', @@ -22,12 +23,9 @@ const supportedVideoTypes = Object.entries(videoTypes) video = null; const looksLikeAVideoFile = (attachment) => { - if (attachment.inProgress) { + if (attachment.meta?.inProgress) { return false; } - if (attachment.url.endsWith('.mp4')) { - return true; - } const lowercaseFileName = attachment.fileName.toLowerCase(); for (const extension of supportedVideoTypes) { @@ -48,6 +46,7 @@ export default function PostAttachments(props) { const imageAttachments = []; const audioAttachments = []; const videoAttachments = []; + const likeAVideoAttachments = []; const generalAttachments = []; attachments.forEach((attachment) => { @@ -55,8 +54,10 @@ export default function PostAttachments(props) { imageAttachments.push(attachment); } else if (attachment.mediaType === 'audio') { audioAttachments.push(attachment); - } else if (attachment.mediaType === 'general' && looksLikeAVideoFile(attachment)) { + } else if (attachment.mediaType === 'video' && !attachment.meta?.inProgress) { videoAttachments.push(attachment); + } else if (attachment.mediaType === 'general' && looksLikeAVideoFile(attachment)) { + likeAVideoAttachments.push(attachment); } else { generalAttachments.push(attachment); } @@ -92,6 +93,21 @@ export default function PostAttachments(props) { false ); + const likeAVideoAttachmentsNodes = likeAVideoAttachments.map((attachment) => ( + + )); + const likeVideoAttachmentsContainer = + likeAVideoAttachments.length > 0 ? ( +
{likeAVideoAttachmentsNodes}
+ ) : ( + false + ); + const videoAttachmentsNodes = videoAttachments.map((attachment) => (
diff --git a/styles/shared/attachments.scss b/styles/shared/attachments.scss index 7f340e673..a9f8cf431 100644 --- a/styles/shared/attachments.scss +++ b/styles/shared/attachments.scss @@ -162,9 +162,15 @@ font-size: 2em; } + .attachment--video { + display: inline-flex; + border: 1px solid silver; + padding: 1px; + } + video { max-width: 100%; - max-height: 400px; + height: auto; background-color: #eee; } } diff --git a/styles/shared/media-viewer.scss b/styles/shared/media-viewer.scss index 1a591861e..768db0b5d 100644 --- a/styles/shared/media-viewer.scss +++ b/styles/shared/media-viewer.scss @@ -1,8 +1,3 @@ -video:not(.pswp-media__embed) { - width: 100% !important; - height: auto !important; -} - .media-link { .icon-bond { white-space: nowrap; From 8cc5f93a0d499996a6719f749cc5963f1589a5a1 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Tue, 28 Jan 2025 13:20:10 +0300 Subject: [PATCH 09/55] Fully rewrite object in store in mergeByIds helper --- src/redux/reducers.js | 6 +----- src/redux/reducers/helpers.js | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 027c88d90..3bd25c46c 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -683,11 +683,7 @@ export function attachments(state = {}, action) { case response(ActionTypes.GET_ATTACHMENT_INFO): case ActionTypes.SET_ATTACHMENT: case ActionTypes.ADD_ATTACHMENT_RESPONSE: { - const attObj = action.payload.attachments; - return { - ...state, - [attObj.id]: attObj, - }; + return mergeByIds(state, [action.payload.attachments], { update: true }); } } return state; diff --git a/src/redux/reducers/helpers.js b/src/redux/reducers/helpers.js index 1747e7f08..3b0f551ff 100644 --- a/src/redux/reducers/helpers.js +++ b/src/redux/reducers/helpers.js @@ -40,11 +40,7 @@ export function mergeByIds(state, list, { insert = true, update = false } = {}) const newState = { ...state }; for (const it of list) { - if (!newState[it.id] && insert) { - newState[it.id] = it; - } else if (newState[it.id] && update) { - newState[it.id] = { ...newState[it.id], ...it }; - } + newState[it.id] = it; } return newState; } From 87ca1c3af45c3ec5117e9adf7a31e87b27275bd9 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 31 Jan 2025 16:10:09 +0300 Subject: [PATCH 10/55] Fix links to the 'general' attachments --- src/components/post/post-attachment-general.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/post/post-attachment-general.jsx b/src/components/post/post-attachment-general.jsx index 8aeb806ef..b69a27b44 100644 --- a/src/components/post/post-attachment-general.jsx +++ b/src/components/post/post-attachment-general.jsx @@ -4,6 +4,7 @@ import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { formatFileSize } from '../../utils'; import { Icon } from '../fontawesome-icons'; +import { attachmentPreviewUrl } from '../../services/api'; class GeneralAttachment extends PureComponent { handleClickOnRemoveAttachment = () => { @@ -28,7 +29,12 @@ class GeneralAttachment extends PureComponent { return (
- + {nameAndSize} From 4cfd84577e3dc0c5e0f5a7d68bc17f3cf53a029d Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 1 Feb 2025 17:48:11 +0300 Subject: [PATCH 11/55] Create a new new inline photogallery --- package.json | 1 + src/components/create-post.jsx | 4 +- .../post/attachments/attachments.jsx | 79 ++++++++++ .../post/attachments/attachments.module.scss | 148 ++++++++++++++++++ src/components/post/attachments/audio.jsx | 38 +++++ src/components/post/attachments/general.jsx | 37 +++++ src/components/post/attachments/geometry.js | 88 +++++++++++ .../post/attachments/nsfw-canvas.jsx | 33 ++++ .../post/attachments/original-link.jsx | 40 +++++ .../post/attachments/use-width-of.js | 37 +++++ .../post/attachments/visual-container.jsx | 117 ++++++++++++++ src/components/post/attachments/visual.jsx | 109 +++++++++++++ src/components/post/post-edit-form.jsx | 4 +- src/components/post/post.jsx | 7 +- src/services/lightbox-actual.js | 27 ++++ yarn.lock | 8 + 16 files changed, 768 insertions(+), 9 deletions(-) create mode 100644 src/components/post/attachments/attachments.jsx create mode 100644 src/components/post/attachments/attachments.module.scss create mode 100644 src/components/post/attachments/audio.jsx create mode 100644 src/components/post/attachments/general.jsx create mode 100644 src/components/post/attachments/geometry.js create mode 100644 src/components/post/attachments/nsfw-canvas.jsx create mode 100644 src/components/post/attachments/original-link.jsx create mode 100644 src/components/post/attachments/use-width-of.js create mode 100644 src/components/post/attachments/visual-container.jsx create mode 100644 src/components/post/attachments/visual.jsx diff --git a/package.json b/package.json index 2cbb9601e..4c89a44d7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "memoize-one": "~6.0.0", "mousetrap": "~1.6.5", "photoswipe": "~5.4.4", + "photoswipe-video-plugin": "~1.0.2", "porter-stemmer": "~0.9.1", "prop-types": "~15.8.1", "react": "~18.3.1", diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index 5f32279eb..e7598355f 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -20,7 +20,6 @@ import { Throbber } from './throbber'; import { useFileChooser } from './uploader/file-chooser'; import { useUploader } from './uploader/uploader'; import { UploadProgress } from './uploader/progress'; -import PostAttachments from './post/post-attachments'; import { useBool } from './hooks/bool'; import { useServerValue } from './hooks/server-info'; import { Selector } from './feeds-selector/selector'; @@ -29,6 +28,7 @@ import { CommaAndSeparated } from './separated'; import { usePrivacyCheck } from './feeds-selector/privacy-check'; import { PreventPageLeaving } from './prevent-page-leaving'; import { Autocomplete } from './autocomplete/autocomplete'; +import { Attachments } from './post/attachments/attachments'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; const selectMaxPostLength = (serverInfo) => serverInfo.maxTextLength.post; @@ -316,7 +316,7 @@ export default function CreatePost({ sendTo, isDirects }) { )} - +
); diff --git a/src/components/post/attachments/attachments.jsx b/src/components/post/attachments/attachments.jsx new file mode 100644 index 000000000..c80c0537b --- /dev/null +++ b/src/components/post/attachments/attachments.jsx @@ -0,0 +1,79 @@ +import cn from 'classnames'; +import { useMemo } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import { pluralForm } from '../../../utils'; +import ErrorBoundary from '../../error-boundary'; +import { GeneralAttachment } from './general'; +import { AudioAttachment } from './audio'; +import style from './attachments.module.scss'; +import { VisualContainer } from './visual-container'; + +export function Attachments({ + attachmentIds, + isNSFW, + isExpanded, + removeAttachment, + reorderImageAttachments, + postId, +}) { + const attachments = useSelector( + (state) => (attachmentIds || []).map((id) => state.attachments[id]).filter(Boolean), + shallowEqual, + ); + + const [visualAttachments, audialAttachments, generalAttachments] = useMemo(() => { + const visual = []; + const audial = []; + const general = []; + for (const a of attachments) { + if (a.mediaType === 'image' || (a.mediaType === 'video' && !a.meta?.inProgress)) { + visual.push(a); + } else if (a.mediaType === 'audio') { + audial.push(a); + } else { + general.push(a); + } + } + + return [visual, audial, general]; + }, [attachments]); + + if (attachments.length === 0) { + return null; + } + + return ( +
+ + {visualAttachments.length > 0 && ( + + )} + {audialAttachments.length > 0 && ( +
+ {audialAttachments.map((a) => ( + + ))} +
+ )} + {generalAttachments.length > 0 && ( +
+ {generalAttachments.map((a) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/post/attachments/attachments.module.scss b/src/components/post/attachments/attachments.module.scss new file mode 100644 index 000000000..043341990 --- /dev/null +++ b/src/components/post/attachments/attachments.module.scss @@ -0,0 +1,148 @@ +.attachments a { + color: #000088; + text-decoration: none; + + &:hover .original-link__text { + text-decoration: underline; + } +} + +.container { + margin-bottom: 1em; +} + +.attachment { + margin-bottom: 0.5em; +} + +.attachment--audio { + margin-bottom: 1em; +} + +.container--visual { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--gap, 5px); +} + +.visual__filler { + flex: 1; +} + +.visual__link { + position: relative; + display: inline-flex; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 1); + } +} + +.container--sortable .visual__link { + cursor: move; +} + +.visual__overlay { + position: absolute; + color: #fff; + background-color: #333; + outline: 1px solid rgba(255, 255, 255, 0.2); + opacity: 0.75; + font-size: 0.75em; + padding: 0 0.5em; + border-radius: 0.35em; + font-weight: bold; + display: flex; + gap: 0.35em; + align-items: center; + z-index: 1; +} + +.visual__overlay--button { + border: none; + opacity: 1; + cursor: pointer; + outline: 3px solid rgba(255, 255, 255, 0.5); + + &:hover { + outline: 3px solid rgba(255, 255, 255, 1); + } +} + +.visual__overlay--info { + bottom: 0.35em; + right: 0.35em; + pointer-events: none; +} + +.visual__overlay--info :global(.fa-icon) { + font-size: 0.85em; +} + +.visual__overlay--remove { + top: 0.3em; + right: 0.3em; + padding: 0.2em; + font-size: 1.2em; +} + +.audio__player { + width: 100%; +} + +.original-link__container { + display: flex; + align-items: flex-start; + gap: 0.5em; +} + +.original-link__icon { + opacity: 0.75; + flex: none; + color: initial; + margin-top: 0.2em; +} + +.original-link__text { + flex: 1; +} + +.original-link__size { + color: initial; + opacity: 0.5; +} + +.original-link__remove { + flex: none; + color: #fff; + background-color: #333; + border: none; + display: flex; + cursor: pointer; + padding: 0.2em; + font-size: 1.2em; + border-radius: 0.35em; + outline: 3px solid rgba(255, 255, 255, 0.5); +} + +.nsfw-canvas { + position: absolute; + width: 100%; + height: 100%; + filter: contrast(1.2); + background-color: #ccc; +} + +.nsfw-canvas__label { + color: #ccc; + font-weight: bold; + font-size: 1.25em; + pointer-events: none; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/components/post/attachments/audio.jsx b/src/components/post/attachments/audio.jsx new file mode 100644 index 000000000..fbbad75c5 --- /dev/null +++ b/src/components/post/attachments/audio.jsx @@ -0,0 +1,38 @@ +import cn from 'classnames'; +import { faHeadphones } from '@fortawesome/free-solid-svg-icons'; +import { attachmentPreviewUrl } from '../../../services/api'; +import { formatFileSize } from '../../../utils'; +import style from './attachments.module.scss'; +import { OriginalLink } from './original-link'; + +export function AudioAttachment({ attachment: att, removeAttachment }) { + const formattedFileSize = formatFileSize(att.fileSize); + + const title = + [att.meta?.['dc:creator'], att.meta?.['dc:relation.isPartOf'], att.meta?.['dc:title']] + .filter(Boolean) + .join(' – ') || att.fileName; + + const titleAndSize = `${title} (${formattedFileSize})`; + + return ( +
+ + {title} + +
+
+
+ ); +} diff --git a/src/components/post/attachments/general.jsx b/src/components/post/attachments/general.jsx new file mode 100644 index 000000000..54d2d9e37 --- /dev/null +++ b/src/components/post/attachments/general.jsx @@ -0,0 +1,37 @@ +import cn from 'classnames'; +import { useEvent } from 'react-use-event-hook'; +import { faPaperclip } from '@fortawesome/free-solid-svg-icons'; +import { formatFileSize } from '../../../utils'; +import style from './attachments.module.scss'; +import { OriginalLink } from './original-link'; + +export function GeneralAttachment({ attachment: att, removeAttachment }) { + const { inProgress = false } = att.meta ?? {}; + + const handleClick = useEvent((e) => { + if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { + return; + } + if (this.props.meta?.inProgress) { + e.preventDefault(); + alert('This file is still being processed'); + } + }); + + const nameAndSize = `${att.fileName} (${inProgress ? 'processing...' : formatFileSize(att.fileSize)})`; + + return ( +
+ +
+ ); +} diff --git a/src/components/post/attachments/geometry.js b/src/components/post/attachments/geometry.js new file mode 100644 index 000000000..93766444e --- /dev/null +++ b/src/components/post/attachments/geometry.js @@ -0,0 +1,88 @@ +const thumbnailMaxWidth = 525; +const thumbnailMaxHeight = 175; + +const videoMaxWidth = 500; +const videoMaxHeight = 400; + +export function thumbnailSize(att) { + return fitIntoBox(att, thumbnailMaxWidth, thumbnailMaxHeight); +} + +export function videoSize(att) { + return fitIntoBox(att, videoMaxWidth, videoMaxHeight); +} + +export function fitIntoBox(att, boxWidth, boxHeight) { + const [width, height] = [att.previewWidth ?? att.width, att.previewHeight ?? att.height]; + boxWidth = Math.min(boxWidth, width); + boxHeight = Math.min(boxHeight, height); + const wRatio = width / boxWidth; + const hRatio = height / boxHeight; + + if (wRatio > hRatio) { + return { width: boxWidth, height: Math.round(height / wRatio) }; + } + + return { width: Math.round(width / hRatio), height: boxHeight }; +} + +/** + * @param {number[]} ratios + * @param {number} containerWidth + * @param {number} thumbArea + * @param {number} gap + * @returns {{width: number, height: number}[][]} + */ +export function getGallerySizes(ratios, containerWidth, thumbArea, gap) { + let start = 0; + const lines = []; + while (start < ratios.length) { + const line = getGalleryLine(ratios.slice(start), containerWidth, thumbArea, gap); + lines.push(line); + start += line.length; + } + return lines; +} + +/** + * @param {number[]} ratios + * @param {number} containerWidth + * @param {number} thumbArea + * @param {number} gap + * @returns {{width: number, height: number}[]} + */ +function getGalleryLine(ratios, containerWidth, thumbArea, gap) { + let avgRatio = 0; + let prevHeight = 0; + let prevDiff = Infinity; + let n = 0; + for (const ratio of ratios) { + n++; + avgRatio = (avgRatio * (n - 1) + ratio) / n; // Average ratio of one item + const avgWidth = (containerWidth - gap * (n - 1)) / n; // Average width of one item + const height = avgWidth / avgRatio; + const avgArea = height * avgWidth; // Average area of one item + const diff = Math.abs(thumbArea - avgArea); + if (diff >= prevDiff) { + return ratios + .slice(0, n - 1) + .map((r) => ({ width: Math.floor(r * prevHeight), height: prevHeight })); + // return { count: n - 1, height: prevHeight }; + } + prevDiff = diff; + prevHeight = Math.round(height); + } + + // Last line + if (prevDiff > 0.1 * thumbArea) { + const height = Math.round(Math.sqrt(thumbArea / avgRatio)); + return ratios.map((r) => ({ width: Math.floor(r * height), height })); + //return { + // count: n, + // height: Math.round(Math.sqrt(thumbArea / avgRatio)), + // }; + } + return ratios.map((r) => ({ width: Math.floor(r * prevHeight), height: prevHeight })); + + // return { length: n, height: prevHeight }; +} diff --git a/src/components/post/attachments/nsfw-canvas.jsx b/src/components/post/attachments/nsfw-canvas.jsx new file mode 100644 index 000000000..e633c30cd --- /dev/null +++ b/src/components/post/attachments/nsfw-canvas.jsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react'; +import style from './attachments.module.scss'; + +const NSFW_PREVIEW_AREA = 20; + +export function NsfwCanvas({ aspectRatio, src }) { + const canvasWidth = Math.round(Math.sqrt(NSFW_PREVIEW_AREA * aspectRatio)); + const canvasHeight = Math.round(Math.sqrt(NSFW_PREVIEW_AREA / aspectRatio)); + + const ref = useRef(null); + + useEffect(() => { + const canvas = ref.current; + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.onload = () => canvas.isConnected && ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + img.src = src; + // Run only once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +
NSFW
+ + ); +} diff --git a/src/components/post/attachments/original-link.jsx b/src/components/post/attachments/original-link.jsx new file mode 100644 index 000000000..c22fc8e9c --- /dev/null +++ b/src/components/post/attachments/original-link.jsx @@ -0,0 +1,40 @@ +import { useEvent } from 'react-use-event-hook'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { attachmentPreviewUrl } from '../../../services/api'; +import { formatFileSize } from '../../../utils'; +import { Icon } from '../../fontawesome-icons'; +import style from './attachments.module.scss'; + +export function OriginalLink({ + attachment: att, + icon, + children = att.fileName, + removeAttachment, + ...props +}) { + const { inProgress = false } = att.meta ?? {}; + const handleRemove = useEvent(() => removeAttachment?.(att.id)); + return ( + + ); +} diff --git a/src/components/post/attachments/use-width-of.js b/src/components/post/attachments/use-width-of.js new file mode 100644 index 000000000..8fb691340 --- /dev/null +++ b/src/components/post/attachments/use-width-of.js @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; + +const resizeHandlers = new Map(); + +export function useWidthOf(elRef) { + const [width, setWidth] = useState(elRef.current?.offsetWidth || 0); + useEffect(() => { + const el = elRef.current; + resizeHandlers.set(el, setWidth); + const observer = getResizeObserver(); + observer.observe(el); + return () => { + resizeHandlers.delete(el); + observer.unobserve(el); + }; + }, [elRef]); + return width; +} + +let _observer = null; +function getResizeObserver() { + if (!_observer) { + if (globalThis.ResizeObserver) { + _observer = new globalThis.ResizeObserver((entries) => { + for (const entry of entries) { + resizeHandlers.get(entry.target)?.(entry.contentRect.width); + } + }); + } else { + _observer = { + observe() {}, + unobserve() {}, + }; + } + } + return _observer; +} diff --git a/src/components/post/attachments/visual-container.jsx b/src/components/post/attachments/visual-container.jsx new file mode 100644 index 000000000..ce324dea0 --- /dev/null +++ b/src/components/post/attachments/visual-container.jsx @@ -0,0 +1,117 @@ +import cn from 'classnames'; +import { useRef, useMemo } from 'react'; +import { useEvent } from 'react-use-event-hook'; +import { attachmentPreviewUrl } from '../../../services/api'; +import { openLightbox } from '../../../services/lightbox'; +import { lazyComponent } from '../../lazy-component'; +import style from './attachments.module.scss'; +import { VisualAttachment } from './visual'; +import { useWidthOf } from './use-width-of'; +import { fitIntoBox, getGallerySizes } from './geometry'; + +const gap = 8; // px +const thumbArea = 210 ** 2; // px^2 + +const Sortable = lazyComponent(() => import('../../react-sortable'), { + fallback:
Loading component...
, + errorMessage: "Couldn't load Sortable component", +}); + +export function VisualContainer({ + attachments, + isNSFW, + removeAttachment, + reorderImageAttachments, + postId, +}) { + const containerRef = useRef(null); + const containerWidth = useWidthOf(containerRef); + + const ratios = attachments.map((a) => a.width / a.height); + let sizes = getGallerySizes(ratios, containerWidth - 5, thumbArea, gap).flat(); + + const singleImage = attachments.length === 1; + const withSortable = !!removeAttachment && attachments.length > 1; + + if (singleImage) { + sizes = [fitIntoBox(attachments[0], 500, 300)]; + } + + const lightboxItems = useMemo( + () => + attachments.map((a) => ({ + ...(a.mediaType === 'image' + ? { type: 'image', src: attachmentPreviewUrl(a.id, 'image') } + : { + type: 'video', + videoSrc: attachmentPreviewUrl(a.id, 'video'), + msrc: attachmentPreviewUrl(a.id, 'image'), + }), + originalSrc: attachmentPreviewUrl(a.id, 'original'), + width: a.previewWidth ?? a.width, + height: a.previewHeight ?? a.height, + pid: `${postId?.slice(0, 8) ?? 'new-post'}-${a.id.slice(0, 8)}`, + })), + [attachments, postId], + ); + + const handleClick = useEvent((e) => { + if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { + return; + } + e.preventDefault(); + const { currentTarget: el } = e; + const index = lightboxItems.findIndex((i) => i.pid === el.dataset.pid); + openLightbox(index, lightboxItems, el.target); + }); + + const setSortedList = useEvent((list) => reorderImageAttachments(list.map((a) => a.id))); + + const previews = attachments.map((a, i) => ( + + )); + + return ( +
+ {withSortable ? ( + + {previews} +
+ + ) : ( +
+ {previews} +
+
+ )} +
+ ); +} diff --git a/src/components/post/attachments/visual.jsx b/src/components/post/attachments/visual.jsx new file mode 100644 index 000000000..8c48472ba --- /dev/null +++ b/src/components/post/attachments/visual.jsx @@ -0,0 +1,109 @@ +import cn from 'classnames'; +import { useEvent } from 'react-use-event-hook'; +import { faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { useState } from 'react'; +import { attachmentPreviewUrl } from '../../../services/api'; +import { formatFileSize } from '../../../utils'; +import { Icon } from '../../fontawesome-icons'; +import { useMediaQuery } from '../../hooks/media-query'; +import style from './attachments.module.scss'; +import { NsfwCanvas } from './nsfw-canvas'; + +// import { thumbnailSize } from './geometry'; + +export function VisualAttachment({ + attachment: att, + pictureId, + width, + height, + handleClick, + removeAttachment, + isNSFW, +}) { + const nameAndSize = `${att.fileName} (${formatFileSize(att.fileSize)}, ${att.width}×${att.height}px)`; + const alt = `${att.mediaType === 'image' ? 'Image' : 'Video'} attachment ${att.fileName}`; + + // const { width, height } = thumbnailSize(att); + const hiDpi = useMediaQuery('(min-resolution: 1.5x)') ? 2 : 1; + + const handleMouseEnter = useEvent((e) => { + e.target.play(); + }); + const handleMouseLeave = useEvent((e) => { + e.target.pause(); + e.target.currentTime = 0; + }); + const [currentTime, setCurrentTime] = useState(0); + const handleTimeUpdate = useEvent((e) => setCurrentTime(Math.floor(e.target.currentTime))); + + const handleRemove = useEvent((e) => { + e.stopPropagation(); + e.preventDefault(); + removeAttachment?.(att.id); + }); + + const imageSrc = attachmentPreviewUrl(att.id, 'image', hiDpi * width, hiDpi * height); + const videoSrc = attachmentPreviewUrl(att.id, 'video', hiDpi * width, hiDpi * height); + + return ( + + {att.mediaType === 'image' ? ( + {alt} + ) : ( + <> + + ); +} + +function formatTime(duration) { + const hours = Math.floor(duration / 3600); + const minutes = Math.floor(duration / 60); + const seconds = Math.floor(duration) % 60; + + return `${hours ? `${hours.toString()}:` : ''}${hours ? minutes.toString().padStart(2, '0') : minutes.toString()}:${seconds.toString().padStart(2, '0')}`; +} diff --git a/src/components/post/post-edit-form.jsx b/src/components/post/post-edit-form.jsx index 0648f7391..5b42c7901 100644 --- a/src/components/post/post-edit-form.jsx +++ b/src/components/post/post-edit-form.jsx @@ -24,7 +24,7 @@ import { ButtonLink } from '../button-link'; import { usePrivacyCheck } from '../feeds-selector/privacy-check'; import { doneEditingAndDeleteDraft, existingPostURI, getDraft } from '../../services/drafts'; import { Autocomplete } from '../autocomplete/autocomplete'; -import PostAttachments from './post-attachments'; +import { Attachments } from './attachments/attachments'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; const selectMaxPostLength = (serverInfo) => serverInfo.maxTextLength.post; @@ -233,7 +233,7 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach )} - +
); diff --git a/src/components/post/post.jsx b/src/components/post/post.jsx index 15f984432..46b07983d 100644 --- a/src/components/post/post.jsx +++ b/src/components/post/post.jsx @@ -38,13 +38,13 @@ import { UnhideOptions, HideLink } from './post-hides-ui'; import PostMoreLink from './post-more-link'; import PostLikeLink from './post-like-link'; import PostHeader from './post-header'; -import PostAttachments from './post-attachments'; import PostComments from './post-comments'; import PostLikes from './post-likes'; import { PostContext } from './post-context'; import { PostEditForm } from './post-edit-form'; import { PostProvider } from './post-comment-provider'; import { DraftIndicator } from './draft-indicator'; +import { Attachments } from './attachments/attachments'; class Post extends Component { selectFeeds; @@ -457,14 +457,11 @@ class Post extends Component { <> {this.props.attachments.length > 0 && (
- {!this.props.noImageAttachments && props.isNSFW && (
diff --git a/src/services/lightbox-actual.js b/src/services/lightbox-actual.js index 6f1dcb021..1953f8287 100644 --- a/src/services/lightbox-actual.js +++ b/src/services/lightbox-actual.js @@ -1,6 +1,7 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable unicorn/prefer-query-selector */ import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import PhotoSwipeVideoPlugin from 'photoswipe-video-plugin'; import Mousetrap from 'mousetrap'; import pswpModule from 'photoswipe'; import 'photoswipe/photoswipe.css'; @@ -29,6 +30,13 @@ const fullscreenIconsHtml = ` `; +const downloadIconHtml = { + isCustomSVG: true, + inner: + '', + outlineID: 'pswp__icn-download', +}; + function initLightbox() { const lightbox = new PhotoSwipeLightbox({ clickToCloseNonZoomable: false, @@ -46,6 +54,8 @@ function initLightbox() { pswpModule, }); + new PhotoSwipeVideoPlugin(lightbox, {}); + // Add fullscreen button lightbox.on('uiRegister', () => { if (!fsApi) { @@ -66,6 +76,23 @@ function initLightbox() { }, }); + lightbox.pswp.ui.registerElement({ + name: 'download-button', + order: 10, + isButton: true, + tagName: 'a', + html: downloadIconHtml, + onInit: (el, pswp) => { + el.setAttribute('download', ''); // Does not work for cross-origin links:( + el.setAttribute('target', '_blank'); + el.setAttribute('rel', 'noopener'); + + pswp.on('change', () => { + el.href = pswp.currSlide.data.originalSrc; + }); + }, + }); + const h = () => document.documentElement.classList.toggle('pswp__fullscreen-mode', !!fsApi.isFullscreen()); diff --git a/yarn.lock b/yarn.lock index 20775ac20..5c514cdc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8154,6 +8154,13 @@ __metadata: languageName: node linkType: hard +"photoswipe-video-plugin@npm:~1.0.2": + version: 1.0.2 + resolution: "photoswipe-video-plugin@npm:1.0.2" + checksum: 10c0/b48072271b89d0054d1db2edb65ee1cca96c2483afac48887a5c289c865eea7fb18d90052d67c49a90149e8ef9067155e377f6eb8e13ec81c2c81ed4ba8e734b + languageName: node + linkType: hard + "photoswipe@npm:~5.4.4": version: 5.4.4 resolution: "photoswipe@npm:5.4.4" @@ -8798,6 +8805,7 @@ __metadata: node-html-parser: "npm:~6.1.13" npm-run-all: "npm:~4.1.5" photoswipe: "npm:~5.4.4" + photoswipe-video-plugin: "npm:~1.0.2" porter-stemmer: "npm:~0.9.1" prettier: "npm:~3.3.3" prop-types: "npm:~15.8.1" From 16db9092e6ec9cbee4090bde11e544d5234c32ee Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Tue, 4 Feb 2025 12:12:33 +0300 Subject: [PATCH 12/55] Show animated images as a looped video without controls --- .../post/attachments/visual-container.jsx | 1 + src/services/lightbox-actual.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/components/post/attachments/visual-container.jsx b/src/components/post/attachments/visual-container.jsx index ce324dea0..973bdf93d 100644 --- a/src/components/post/attachments/visual-container.jsx +++ b/src/components/post/attachments/visual-container.jsx @@ -46,6 +46,7 @@ export function VisualContainer({ type: 'video', videoSrc: attachmentPreviewUrl(a.id, 'video'), msrc: attachmentPreviewUrl(a.id, 'image'), + meta: a.meta ?? {}, }), originalSrc: attachmentPreviewUrl(a.id, 'original'), width: a.previewWidth ?? a.width, diff --git a/src/services/lightbox-actual.js b/src/services/lightbox-actual.js index 1953f8287..3f4b3bb91 100644 --- a/src/services/lightbox-actual.js +++ b/src/services/lightbox-actual.js @@ -191,6 +191,20 @@ function initLightbox() { setTimeout(() => window.removeEventListener('scroll', h, { once: true }), 500); }); + // Show animated images as a looped video without controls + lightbox.on('contentLoad', ({ content }) => { + const { data, element } = content; + if (data.type === 'video') { + if (data.meta.animatedImage) { + element.muted = true; + element.loop = true; + element.controls = false; + } else if (data.meta.silent) { + element.muted = true; + } + } + }); + // Init lightbox.init(); return lightbox; From a140d1e0a14e97df9126cc9a0c3ac78f39de8e88 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Tue, 4 Feb 2025 15:34:36 +0300 Subject: [PATCH 13/55] Improve inline lightbox layout --- .../post/attachments/attachments.module.scss | 19 +++-- src/components/post/attachments/geometry.js | 28 ++------ .../post/attachments/nsfw-canvas.jsx | 4 +- .../post/attachments/visual-container.jsx | 69 ++++++++++++++----- src/components/post/attachments/visual.jsx | 29 ++++++-- 5 files changed, 94 insertions(+), 55 deletions(-) diff --git a/src/components/post/attachments/attachments.module.scss b/src/components/post/attachments/attachments.module.scss index 043341990..c1f12bcb2 100644 --- a/src/components/post/attachments/attachments.module.scss +++ b/src/components/post/attachments/attachments.module.scss @@ -21,13 +21,22 @@ .container--visual { display: flex; - flex-wrap: wrap; - justify-content: space-between; + flex-direction: column; gap: var(--gap, 5px); } -.visual__filler { - flex: 1; +.container--sortable { + flex-flow: row wrap; +} + +.container-visual__row { + display: flex; + gap: var(--gap, 5px); + justify-content: space-between; + + &:last-child { + justify-content: flex-start; + } } .visual__link { @@ -133,7 +142,7 @@ width: 100%; height: 100%; filter: contrast(1.2); - background-color: #ccc; + background-color: #999; } .nsfw-canvas__label { diff --git a/src/components/post/attachments/geometry.js b/src/components/post/attachments/geometry.js index 93766444e..b4430d947 100644 --- a/src/components/post/attachments/geometry.js +++ b/src/components/post/attachments/geometry.js @@ -1,15 +1,8 @@ -const thumbnailMaxWidth = 525; -const thumbnailMaxHeight = 175; +const legacyThumbnailMaxWidth = 525; +const legacyThumbnailMaxHeight = 175; -const videoMaxWidth = 500; -const videoMaxHeight = 400; - -export function thumbnailSize(att) { - return fitIntoBox(att, thumbnailMaxWidth, thumbnailMaxHeight); -} - -export function videoSize(att) { - return fitIntoBox(att, videoMaxWidth, videoMaxHeight); +export function legacyThumbnailSize(att) { + return fitIntoBox(att, legacyThumbnailMaxWidth, legacyThumbnailMaxHeight); } export function fitIntoBox(att, boxWidth, boxHeight) { @@ -62,27 +55,20 @@ function getGalleryLine(ratios, containerWidth, thumbArea, gap) { const avgWidth = (containerWidth - gap * (n - 1)) / n; // Average width of one item const height = avgWidth / avgRatio; const avgArea = height * avgWidth; // Average area of one item - const diff = Math.abs(thumbArea - avgArea); - if (diff >= prevDiff) { + const diff = Math.log(avgArea / thumbArea); + if (Math.abs(diff) >= Math.abs(prevDiff)) { return ratios .slice(0, n - 1) .map((r) => ({ width: Math.floor(r * prevHeight), height: prevHeight })); - // return { count: n - 1, height: prevHeight }; } prevDiff = diff; prevHeight = Math.round(height); } // Last line - if (prevDiff > 0.1 * thumbArea) { + if (prevDiff > 0.1) { const height = Math.round(Math.sqrt(thumbArea / avgRatio)); return ratios.map((r) => ({ width: Math.floor(r * height), height })); - //return { - // count: n, - // height: Math.round(Math.sqrt(thumbArea / avgRatio)), - // }; } return ratios.map((r) => ({ width: Math.floor(r * prevHeight), height: prevHeight })); - - // return { length: n, height: prevHeight }; } diff --git a/src/components/post/attachments/nsfw-canvas.jsx b/src/components/post/attachments/nsfw-canvas.jsx index e633c30cd..f6e986a22 100644 --- a/src/components/post/attachments/nsfw-canvas.jsx +++ b/src/components/post/attachments/nsfw-canvas.jsx @@ -15,9 +15,7 @@ export function NsfwCanvas({ aspectRatio, src }) { const img = new Image(); img.onload = () => canvas.isConnected && ctx.drawImage(img, 0, 0, canvas.width, canvas.height); img.src = src; - // Run only once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [src]); return ( <> diff --git a/src/components/post/attachments/visual-container.jsx b/src/components/post/attachments/visual-container.jsx index 973bdf93d..9386f39c8 100644 --- a/src/components/post/attachments/visual-container.jsx +++ b/src/components/post/attachments/visual-container.jsx @@ -7,7 +7,7 @@ import { lazyComponent } from '../../lazy-component'; import style from './attachments.module.scss'; import { VisualAttachment } from './visual'; import { useWidthOf } from './use-width-of'; -import { fitIntoBox, getGallerySizes } from './geometry'; +import { fitIntoBox, getGallerySizes, legacyThumbnailSize } from './geometry'; const gap = 8; // px const thumbArea = 210 ** 2; // px^2 @@ -28,13 +28,13 @@ export function VisualContainer({ const containerWidth = useWidthOf(containerRef); const ratios = attachments.map((a) => a.width / a.height); - let sizes = getGallerySizes(ratios, containerWidth - 5, thumbArea, gap).flat(); + let sizeRows = getGallerySizes(ratios, containerWidth - 1, thumbArea, gap); const singleImage = attachments.length === 1; const withSortable = !!removeAttachment && attachments.length > 1; if (singleImage) { - sizes = [fitIntoBox(attachments[0], 500, 300)]; + sizeRows = [[fitIntoBox(attachments[0], 500, 300)]]; } const lightboxItems = useMemo( @@ -68,20 +68,53 @@ export function VisualContainer({ const setSortedList = useEvent((list) => reorderImageAttachments(list.map((a) => a.id))); - const previews = attachments.map((a, i) => ( - - )); + const previews = []; + if (withSortable) { + // Use the single container and the fixed legacy sizes + for (const [i, a] of attachments.entries()) { + const { width, height } = legacyThumbnailSize(a); + previews.push( + , + ); + } + } else { + // Use multiple rows and the dynamic sizes + let n = 0; + for (const sizes of sizeRows) { + const atts = attachments.slice(n, n + sizes.length); + const key = atts.map((a) => a.id).join('-'); + previews.push( +
+ {atts.map((a, i) => ( + + ))} +
, + ); + n += sizes.length; + } + } return (
@@ -99,7 +132,6 @@ export function VisualContainer({ preventOnFilter={false} > {previews} -
) : (
{previews} -
)}
diff --git a/src/components/post/attachments/visual.jsx b/src/components/post/attachments/visual.jsx index 8c48472ba..a0ccdd699 100644 --- a/src/components/post/attachments/visual.jsx +++ b/src/components/post/attachments/visual.jsx @@ -1,7 +1,7 @@ import cn from 'classnames'; import { useEvent } from 'react-use-event-hook'; import { faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; -import { useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { attachmentPreviewUrl } from '../../../services/api'; import { formatFileSize } from '../../../utils'; import { Icon } from '../../fontawesome-icons'; @@ -9,8 +9,6 @@ import { useMediaQuery } from '../../hooks/media-query'; import style from './attachments.module.scss'; import { NsfwCanvas } from './nsfw-canvas'; -// import { thumbnailSize } from './geometry'; - export function VisualAttachment({ attachment: att, pictureId, @@ -23,7 +21,22 @@ export function VisualAttachment({ const nameAndSize = `${att.fileName} (${formatFileSize(att.fileSize)}, ${att.width}×${att.height}px)`; const alt = `${att.mediaType === 'image' ? 'Image' : 'Video'} attachment ${att.fileName}`; - // const { width, height } = thumbnailSize(att); + const [prvWidth, setPrvWidth] = useState(width); + const [prvHeight, setPrvHeight] = useState(height); + + useLayoutEffect(() => { + // Don't update preview URLs if the size hasn't changed by more than the minimum size difference + const minSizeDifference = 40; + if ( + Math.abs(width - prvWidth) < minSizeDifference && + Math.abs(height - prvHeight) < minSizeDifference + ) { + return; + } + setPrvWidth(width); + setPrvHeight(height); + }, [prvWidth, prvHeight, width, height]); + const hiDpi = useMediaQuery('(min-resolution: 1.5x)') ? 2 : 1; const handleMouseEnter = useEvent((e) => { @@ -42,8 +55,8 @@ export function VisualAttachment({ removeAttachment?.(att.id); }); - const imageSrc = attachmentPreviewUrl(att.id, 'image', hiDpi * width, hiDpi * height); - const videoSrc = attachmentPreviewUrl(att.id, 'video', hiDpi * width, hiDpi * height); + const imageSrc = attachmentPreviewUrl(att.id, 'image', hiDpi * prvWidth, hiDpi * prvHeight); + const videoSrc = attachmentPreviewUrl(att.id, 'video', hiDpi * prvWidth, hiDpi * prvHeight); return ( )} - {isNSFW && !removeAttachment && } + {isNSFW && !removeAttachment && ( + + )} {removeAttachment && ( +
+ )}
, ); n += atts.length; + if (showIcon && isFolded) { + // Show only the first row + break; + } } return ( diff --git a/src/components/post/post.jsx b/src/components/post/post.jsx index 46b07983d..aaad5a701 100644 --- a/src/components/post/post.jsx +++ b/src/components/post/post.jsx @@ -461,7 +461,7 @@ class Post extends Component { postId={props.id} attachmentIds={this.props.attachments} isNSFW={props.isNSFW} - isSinglePost={props.isSinglePost} + isExpanded={props.isSinglePost} /> {!this.props.noImageAttachments && props.isNSFW && (
From 332a934d7c79302774ddab97ea18e69973737362 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 6 Feb 2025 15:37:40 +0300 Subject: [PATCH 27/55] Move visual attachment code to the separate folder --- .../post/attachments/attachments.jsx | 2 +- .../post/attachments/attachments.module.scss | 187 ----------------- .../{visual.jsx => visual/attachment.jsx} | 28 ++- .../container-editable.jsx} | 15 +- .../container-static.jsx} | 21 +- .../container.jsx} | 4 +- .../post/attachments/{ => visual}/geometry.js | 0 .../post/attachments/{ => visual}/hooks.js | 4 +- .../attachments/{ => visual}/nsfw-canvas.jsx | 2 +- .../attachments/visual/visual.module.scss | 194 ++++++++++++++++++ 10 files changed, 228 insertions(+), 229 deletions(-) rename src/components/post/attachments/{visual.jsx => visual/attachment.jsx} (83%) rename src/components/post/attachments/{visual-container-editable.jsx => visual/container-editable.jsx} (81%) rename src/components/post/attachments/{visual-container-static.jsx => visual/container-static.jsx} (85%) rename src/components/post/attachments/{visual-container.jsx => visual/container.jsx} (60%) rename src/components/post/attachments/{ => visual}/geometry.js (100%) rename src/components/post/attachments/{ => visual}/hooks.js (94%) rename src/components/post/attachments/{ => visual}/nsfw-canvas.jsx (94%) create mode 100644 src/components/post/attachments/visual/visual.module.scss diff --git a/src/components/post/attachments/attachments.jsx b/src/components/post/attachments/attachments.jsx index 46a1e55d0..3602e382e 100644 --- a/src/components/post/attachments/attachments.jsx +++ b/src/components/post/attachments/attachments.jsx @@ -6,7 +6,7 @@ import ErrorBoundary from '../../error-boundary'; import { GeneralAttachment } from './general'; import { AudioAttachment } from './audio'; import style from './attachments.module.scss'; -import { VisualContainer } from './visual-container'; +import { VisualContainer } from './visual/container'; export function Attachments({ attachmentIds, diff --git a/src/components/post/attachments/attachments.module.scss b/src/components/post/attachments/attachments.module.scss index d9b49a0de..d792c61e8 100644 --- a/src/components/post/attachments/attachments.module.scss +++ b/src/components/post/attachments/attachments.module.scss @@ -21,174 +21,6 @@ margin-bottom: 1em; } -.container--visual { - display: flex; - flex-direction: column; - gap: var(--gap, 5px); -} - -.container--sortable { - flex-flow: row wrap; -} - -.container-visual__row { - display: flex; - gap: var(--gap, 5px); - position: relative; -} - -.container-visual__fold-box { - flex: 1; - position: relative; - - .container-visual__row--stretched & { - position: absolute; - height: 100%; - right: 0; - } -} - -.container-visual__fold-icon { - cursor: pointer; - color: #8ab; - background-color: #fff; - border-radius: 50%; - padding: 6px; - border: none; - font-size: 26px; - position: absolute; - top: 50%; - left: 0; - right: auto; - transform: translate(-16px, -50%); - - :global(.dark-theme) & { - color: $link-color-dim; - background-color: $bg-color; - } - - .container-visual__row--stretched & { - left: auto; - right: 0; - transform: translate(21px, -50%); - - @media (max-width: 768px) { - transform: translate(14px, -50%); - } - } -} - -.container-visual__row--stretched { - justify-content: space-between; -} - -.visual__link { - color: inherit !important; - position: relative; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); - transition: - box-shadow 0.2s, - background-color 0.2s; - display: grid; - place-content: center; - - &:hover { - box-shadow: 0 0 0 1px rgba(0, 0, 0, 1); - } - - :global(.dark-theme) & { - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2); - - &:hover { - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8); - } - } -} - -.container--sortable .visual__link { - cursor: move; -} - -.visual__image, -.visual__video { - grid-area: 1 / 1; -} - -.visual__image { - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); - background-color: #fff; - - :global(.dark-theme) & { - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3); - background-color: #999; - } -} - -.visual__overlay { - position: absolute; - color: #fff; - background-color: #333; - outline: 1px solid rgba(255, 255, 255, 0.2); - opacity: 0.75; - font-size: 0.75em; - padding: 0 0.5em; - border-radius: 0.35em; - font-weight: bold; - display: flex; - gap: 0.35em; - align-items: center; - z-index: 1; -} - -.visual__overlay--button { - border: none; - opacity: 1; - cursor: pointer; - outline: 3px solid rgba(255, 255, 255, 0.5); - - &:hover { - outline: 3px solid rgba(255, 255, 255, 1); - } -} - -.visual__overlay--info { - bottom: 0.35em; - right: 0.35em; - pointer-events: none; -} - -.visual__overlay--info :global(.fa-icon) { - font-size: 0.85em; -} - -.visual__overlay--remove { - top: 0.3em; - right: 0.3em; - padding: 0.2em; - font-size: 1.2em; -} - -.visual__processing { - opacity: 0.8; - display: flex; - flex-direction: column; - align-items: center; -} - -.visual__processing-icon { - animation: rotate 2s linear infinite; -} - -@keyframes rotate { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - .audio__player { width: 100%; } @@ -227,22 +59,3 @@ border-radius: 0.35em; outline: 3px solid rgba(255, 255, 255, 0.5); } - -.nsfw-canvas { - position: absolute; - width: 100%; - height: 100%; - filter: contrast(1.2); - background-color: rgba(153, 153, 153, 0.95); -} - -.nsfw-canvas__label { - color: #ccc; - font-weight: bold; - font-size: 1.25em; - pointer-events: none; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} diff --git a/src/components/post/attachments/visual.jsx b/src/components/post/attachments/visual/attachment.jsx similarity index 83% rename from src/components/post/attachments/visual.jsx rename to src/components/post/attachments/visual/attachment.jsx index fa0129c1b..a058320cd 100644 --- a/src/components/post/attachments/visual.jsx +++ b/src/components/post/attachments/visual/attachment.jsx @@ -2,11 +2,11 @@ import cn from 'classnames'; import { useEvent } from 'react-use-event-hook'; import { faPlay, faSpinner, faTimes } from '@fortawesome/free-solid-svg-icons'; import { useLayoutEffect, useState } from 'react'; -import { attachmentPreviewUrl } from '../../../services/api'; -import { formatFileSize } from '../../../utils'; -import { Icon } from '../../fontawesome-icons'; -import { useMediaQuery } from '../../hooks/media-query'; -import style from './attachments.module.scss'; +import { attachmentPreviewUrl } from '../../../../services/api'; +import { formatFileSize } from '../../../../utils'; +import { Icon } from '../../../fontawesome-icons'; +import { useMediaQuery } from '../../../hooks/media-query'; +import style from './visual.module.scss'; import { NsfwCanvas } from './nsfw-canvas'; import { fitIntoBox } from './geometry'; @@ -64,7 +64,7 @@ export function VisualAttachment({ return ( {att.meta?.inProgress ? ( -
- +
+ processing
) : ( @@ -85,7 +85,7 @@ export function VisualAttachment({ */} {alt}
+
{atts.map((a, i) => ( ))} {showIcon && ( -
+
From f552648a955635600a306e3abc1f24e0dd1f1654 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 6 Feb 2025 18:20:11 +0300 Subject: [PATCH 29/55] Update tests --- .../post/attachments/visual/hooks.js | 4 +- .../post-attachments.test.jsx.snap | 301 +++++++++--------- test/jest/post-attachments.test.jsx | 50 ++- test/jest/post.test.jsx | 4 +- 4 files changed, 179 insertions(+), 180 deletions(-) diff --git a/src/components/post/attachments/visual/hooks.js b/src/components/post/attachments/visual/hooks.js index f7d530ec6..bcf5b60f4 100644 --- a/src/components/post/attachments/visual/hooks.js +++ b/src/components/post/attachments/visual/hooks.js @@ -5,8 +5,10 @@ import { openLightbox } from '../../../../services/lightbox'; const resizeHandlers = new Map(); +const defaultWidth = process.env.NODE_ENV !== 'test' ? 0 : 600; + export function useWidthOf(elRef) { - const [width, setWidth] = useState(elRef.current?.offsetWidth || 0); + const [width, setWidth] = useState(elRef.current?.offsetWidth || defaultWidth); useEffect(() => { const el = elRef.current; resizeHandlers.set(el, setWidth); diff --git a/test/jest/__snapshots__/post-attachments.test.jsx.snap b/test/jest/__snapshots__/post-attachments.test.jsx.snap index 3850396fd..4dac3c5b2 100644 --- a/test/jest/__snapshots__/post-attachments.test.jsx.snap +++ b/test/jest/__snapshots__/post-attachments.test.jsx.snap @@ -8,202 +8,207 @@ exports[`PostAttachments > Displays all post attachment types 1`] = ` role="region" >
-
- - Image attachment food.jpg - -
-
- -
-
-
- - -
- - - - sunrise.mp4 (117.7 MiB) - - + + Oasis – Wonderwall + + + + 1.2 MiB + + +
+
+
+
diff --git a/test/jest/post-attachments.test.jsx b/test/jest/post-attachments.test.jsx index 74146ed91..6ad500e46 100644 --- a/test/jest/post-attachments.test.jsx +++ b/test/jest/post-attachments.test.jsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; -import PostAttachments from '../../src/components/post/post-attachments'; +import { Attachments } from '../../src/components/post/attachments/attachments'; function renderPostAttachments(attachments = []) { const state = { attachments: attachments.reduce((p, a) => ({ ...p, [a.id]: a }), {}) }; @@ -11,7 +11,7 @@ function renderPostAttachments(attachments = []) { const store = createStore(dummyReducer, state); return render( - a.id)} /> + a.id)} /> , ); } @@ -26,20 +26,11 @@ describe('PostAttachments', () => { const image1 = { id: 'im1', mediaType: 'image', - fileName: 'CAT.JPG', + fileName: 'CAT.jpg', fileSize: 200000, - thumbnailUrl: 'https://thumbnail/CAT.JPG', - url: 'https://media/CAT.JPG', - imageSizes: { - t: { - w: 400, - h: 300, - }, - o: { - w: 2000, - h: 1500, - }, - }, + previewTypes: ['image'], + width: 2000, + height: 1500, }; const image2 = { @@ -47,32 +38,33 @@ describe('PostAttachments', () => { mediaType: 'image', fileName: 'food.jpg', fileSize: 2000, - thumbnailUrl: 'https://thumbnail/food.jpg', - url: 'https://media/food.jpg', - imageSizes: { - o: { - w: 2000, - h: 1500, - }, - }, + previewTypes: ['image'], + width: 2000, + height: 1500, }; const video1 = { id: 'vi1', - mediaType: 'general', + mediaType: 'video', fileName: 'sunrise.mp4', fileSize: 123456789, - url: 'https://media/sunrise.mp4', + previewTypes: ['image', 'video'], + duration: 123, + width: 1920, + height: 1080, }; const audio1 = { id: 'au1', mediaType: 'audio', fileName: 'wonderwall.mp3', - artist: 'Oasis', - title: 'Wonderwall', fileSize: 1234567, - url: 'https://media/wonderwall.mp3', + previewTypes: ['audio'], + duration: 300, + meta: { + 'dc:creator': 'Oasis', + 'dc:title': 'Wonderwall', + }, }; const general1 = { @@ -80,7 +72,7 @@ describe('PostAttachments', () => { mediaType: 'general', fileName: 'rfc.pdf', fileSize: 50000, - url: 'https://media/rfc.pdf', + previewTypes: [], }; const { asFragment } = renderPostAttachments([video1, image1, general1, image2, audio1]); diff --git a/test/jest/post.test.jsx b/test/jest/post.test.jsx index d4a5f5a88..d52a53d59 100644 --- a/test/jest/post.test.jsx +++ b/test/jest/post.test.jsx @@ -12,8 +12,8 @@ vi.mock('../../src/components/post/post-comments', () => ({ }, })); -vi.mock('../../src/components/post/post-attachments', () => ({ - default: ({ attachmentIds }) => { +vi.mock('../../src/components/post/attachments/attachments', () => ({ + Attachments: ({ attachmentIds }) => { return (
{attachmentIds.length > 0 ? `Mocked ${attachmentIds.length} attachments` : ''}
); From 61e7490b892d01b878ac9bd78f6f024d01510956 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 6 Feb 2025 21:52:30 +0300 Subject: [PATCH 30/55] Show like-a-video attachments --- .../post/attachments/attachments.module.scss | 23 ++++++++++ src/components/post/attachments/general.jsx | 23 ++++++++++ .../post/attachments/like-a-video.jsx | 44 +++++++++++++++++++ .../post/attachments/visual/attachment.jsx | 7 ++- .../post/attachments/visual/hooks.js | 37 ++++++++++++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/components/post/attachments/like-a-video.jsx diff --git a/src/components/post/attachments/attachments.module.scss b/src/components/post/attachments/attachments.module.scss index d792c61e8..adaebb53d 100644 --- a/src/components/post/attachments/attachments.module.scss +++ b/src/components/post/attachments/attachments.module.scss @@ -59,3 +59,26 @@ border-radius: 0.35em; outline: 3px solid rgba(255, 255, 255, 0.5); } + +.like-a-video { + margin-bottom: 0.5em; +} + +.like-a-video__button { + border: none; + display: flex; + width: 3em; + height: 3em; + justify-content: center; + align-items: center; + border-radius: 2px; + background-color: rgba(128, 128, 128, 0.2); + font-size: 2em; +} + +.like-a-video__player { + max-width: 100%; + width: 400px; + height: auto; + background-color: #eee; +} diff --git a/src/components/post/attachments/general.jsx b/src/components/post/attachments/general.jsx index 54d2d9e37..16dad8a79 100644 --- a/src/components/post/attachments/general.jsx +++ b/src/components/post/attachments/general.jsx @@ -4,6 +4,26 @@ import { faPaperclip } from '@fortawesome/free-solid-svg-icons'; import { formatFileSize } from '../../../utils'; import style from './attachments.module.scss'; import { OriginalLink } from './original-link'; +import { LikeAVideo } from './like-a-video'; + +const videoTypes = { + mov: 'video/quicktime', + mp4: 'video/mp4; codecs="avc1.42E01E"', + ogg: 'video/ogg; codecs="theora"', + webm: 'video/webm; codecs="vp8, vorbis"', +}; + +const supportedVideoTypes = []; +{ + // find video-types which browser supports + let video = document.createElement('video'); + for (const [extension, mime] of Object.entries(videoTypes)) { + if (video.canPlayType(mime) === 'probably') { + supportedVideoTypes.push(extension); + } + } + video = null; +} export function GeneralAttachment({ attachment: att, removeAttachment }) { const { inProgress = false } = att.meta ?? {}; @@ -20,12 +40,15 @@ export function GeneralAttachment({ attachment: att, removeAttachment }) { const nameAndSize = `${att.fileName} (${inProgress ? 'processing...' : formatFileSize(att.fileSize)})`; + const extension = att.fileName.split('.').pop().toLowerCase(); + return (
+ {supportedVideoTypes.includes(extension) && } setIsOpened(true)); + + const videoRef = useRef(null); + + useStopVideo(videoRef, isOpened); + + if (isOpened) { + return ( +
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/post/attachments/visual/attachment.jsx b/src/components/post/attachments/visual/attachment.jsx index a058320cd..d0b54c9f3 100644 --- a/src/components/post/attachments/visual/attachment.jsx +++ b/src/components/post/attachments/visual/attachment.jsx @@ -1,7 +1,7 @@ import cn from 'classnames'; import { useEvent } from 'react-use-event-hook'; import { faPlay, faSpinner, faTimes } from '@fortawesome/free-solid-svg-icons'; -import { useLayoutEffect, useState } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import { attachmentPreviewUrl } from '../../../../services/api'; import { formatFileSize } from '../../../../utils'; import { Icon } from '../../../fontawesome-icons'; @@ -9,6 +9,7 @@ import { useMediaQuery } from '../../../hooks/media-query'; import style from './visual.module.scss'; import { NsfwCanvas } from './nsfw-canvas'; import { fitIntoBox } from './geometry'; +import { useStopVideo } from './hooks'; export function VisualAttachment({ attachment: att, @@ -61,6 +62,9 @@ export function VisualAttachment({ const imageSrc = attachmentPreviewUrl(att.id, 'image', hiDpi * prvWidth, hiDpi * prvHeight); const videoSrc = attachmentPreviewUrl(att.id, 'video', hiDpi * prvWidth, hiDpi * prvHeight); + const videoRef = useRef(null); + useStopVideo(videoRef, att.mediaType === 'video' && !att.meta?.inProgress); + return (
-
-
-
- - - {artistAndTitle} - - - {props.isEditing && ( - - )} -
-
- ); - } -} - -export default AudioAttachment; diff --git a/src/components/post/post-attachment-general.jsx b/src/components/post/post-attachment-general.jsx deleted file mode 100644 index b69a27b44..000000000 --- a/src/components/post/post-attachment-general.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import { PureComponent } from 'react'; -import { faFile } from '@fortawesome/free-regular-svg-icons'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; - -import { formatFileSize } from '../../utils'; -import { Icon } from '../fontawesome-icons'; -import { attachmentPreviewUrl } from '../../services/api'; - -class GeneralAttachment extends PureComponent { - handleClickOnRemoveAttachment = () => { - this.props.removeAttachment(this.props.id); - }; - - handleClick = (e) => { - if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { - return; - } - if (this.props.meta?.inProgress) { - e.preventDefault(); - alert('This file is still being processed'); - } - }; - - render() { - const { props } = this; - const { inProgress = false } = props.meta ?? {}; - const formattedFileSize = formatFileSize(props.fileSize); - const nameAndSize = `${props.fileName} (${inProgress ? 'processing...' : formattedFileSize})`; - - return ( -
- - - {nameAndSize} - - - {props.isEditing && ( - - )} -
- ); - } -} - -export default GeneralAttachment; diff --git a/src/components/post/post-attachment-geometry.js b/src/components/post/post-attachment-geometry.js deleted file mode 100644 index be2076e01..000000000 --- a/src/components/post/post-attachment-geometry.js +++ /dev/null @@ -1,27 +0,0 @@ -const thumbnailMaxWidth = 525; -const thumbnailMaxHeight = 175; - -const videoMaxWidth = 500; -const videoMaxHeight = 400; - -export function thumbnailSize(att) { - return fitIntoBox(att, thumbnailMaxWidth, thumbnailMaxHeight); -} - -export function videoSize(att) { - return fitIntoBox(att, videoMaxWidth, videoMaxHeight); -} - -function fitIntoBox(att, boxWidth, boxHeight) { - const [width, height] = [att.previewWidth ?? att.width, att.previewHeight ?? att.height]; - boxWidth = Math.min(boxWidth, width); - boxHeight = Math.min(boxHeight, height); - const wRatio = width / boxWidth; - const hRatio = height / boxHeight; - - if (wRatio > hRatio) { - return { width: boxWidth, height: Math.round(height / wRatio) }; - } - - return { width: Math.round(width / hRatio), height: boxHeight }; -} diff --git a/src/components/post/post-attachment-image-container.jsx b/src/components/post/post-attachment-image-container.jsx deleted file mode 100644 index a14fc1d5a..000000000 --- a/src/components/post/post-attachment-image-container.jsx +++ /dev/null @@ -1,177 +0,0 @@ -import pt from 'prop-types'; -import { Component } from 'react'; -import classnames from 'classnames'; -import { faChevronCircleRight } from '@fortawesome/free-solid-svg-icons'; - -import { Icon } from '../fontawesome-icons'; -import { lazyComponent } from '../lazy-component'; -import { openLightbox } from '../../services/lightbox'; -import { attachmentPreviewUrl } from '../../services/api'; -import ImageAttachment from './post-attachment-image'; -import { thumbnailSize } from './post-attachment-geometry'; - -const bordersSize = 4; -const spaceSize = 8; -const arrowSize = 24; - -const Sortable = lazyComponent(() => import('../react-sortable'), { - fallback:
Loading component...
, - errorMessage: "Couldn't load Sortable component", -}); - -export default class ImageAttachmentsContainer extends Component { - static propTypes = { - attachments: pt.array.isRequired, - isSinglePost: pt.bool, - isEditing: pt.bool, - isNSFW: pt.bool, - removeAttachment: pt.func, - reorderImageAttachments: pt.func, - postId: pt.string, - }; - - state = { - containerWidth: 0, - isFolded: true, - needsFolding: false, - }; - - container = null; - - getItemWidths() { - return this.props.attachments - .map((att) => thumbnailSize(att).width) - .map((w) => w + bordersSize + spaceSize); - } - - getContentWidth() { - return this.getItemWidths().reduce((s, w) => s + w, 0); - } - - handleResize = () => { - const containerWidth = this.container.scrollWidth; - if (containerWidth !== this.state.containerWidth) { - this.setState({ - containerWidth, - needsFolding: containerWidth < this.getContentWidth(), - }); - } - }; - - toggleFolding = () => { - this.setState({ isFolded: !this.state.isFolded }); - }; - - handleClickThumbnail(index) { - return (e) => { - if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { - return; - } - e.preventDefault(); - openLightbox(index, this.getPswpItems(), e.target); - }; - } - - getPswpItems() { - return this.props.attachments.map((a) => ({ - src: attachmentPreviewUrl(a.id, 'image'), - width: a.previewWidth ?? a.width, - height: a.previewHeight ?? a.height, - pid: this.getPictureId(a), - })); - } - - getPictureId(a) { - return `${this.props.postId?.slice(0, 8) ?? 'new-post'}-${a.id.slice(0, 8)}`; - } - - componentDidMount() { - if (!this.props.isSinglePost && this.props.attachments.length > 1) { - window.addEventListener('resize', this.handleResize); - this.handleResize(); - } - } - - componentWillUnmount() { - if (!this.props.isSinglePost && this.props.attachments.length > 1) { - window.removeEventListener('resize', this.handleResize); - } - } - - registerContainer = (el) => { - this.container = el; - }; - - setSortedList = (list) => this.props.reorderImageAttachments(list.map((it) => it.id)); - - render() { - const isSingleImage = this.props.attachments.length === 1; - const withSortable = this.props.isEditing && this.props.attachments.length > 1; - const className = classnames({ - 'image-attachments': true, - 'is-folded': this.state.isFolded, - 'needs-folding': this.state.needsFolding, - 'single-image': isSingleImage, - 'sortable-images': withSortable, - }); - - const showFolded = this.state.needsFolding && this.state.isFolded && !this.props.isEditing; - let lastVisibleIndex = 0; - if (showFolded) { - let width = 0; - this.getItemWidths().forEach((w, i) => { - width += w; - if (width + arrowSize < this.state.containerWidth) { - lastVisibleIndex = i; - } - }); - } - - const allImages = this.props.attachments.map((a, i) => ( - lastVisibleIndex} - pictureId={this.getPictureId(a)} - isNSFW={this.props.isNSFW} - {...a} - /> - )); - - return ( -
- {withSortable ? ( - - {allImages} - - ) : ( - allImages - )} - {isSingleImage || this.props.isEditing ? ( - false - ) : ( -
- -
- )} -
- ); - } -} diff --git a/src/components/post/post-attachment-image.jsx b/src/components/post/post-attachment-image.jsx deleted file mode 100644 index 2120aa802..000000000 --- a/src/components/post/post-attachment-image.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { PureComponent, createRef } from 'react'; -import classnames from 'classnames'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; - -import { formatFileSize } from '../../utils'; -import { Icon } from '../fontawesome-icons'; -import { attachmentPreviewUrl } from '../../services/api'; -import { thumbnailSize } from './post-attachment-geometry'; - -const NSFW_PREVIEW_AREA = 20; - -class PostAttachmentImage extends PureComponent { - canvasRef = createRef(null); - - handleRemoveImage = () => { - this.props.removeAttachment(this.props.id); - }; - - componentDidMount() { - const nsfwCanvas = this.canvasRef.current; - if (!nsfwCanvas) { - return; - } - const { width, height } = thumbnailSize(this.props); - const ctx = nsfwCanvas.getContext('2d'); - ctx.fillStyle = '#cccccc'; - ctx.fillRect(0, 0, nsfwCanvas.width, nsfwCanvas.height); - const img = new Image(); - img.onload = () => - nsfwCanvas.isConnected && ctx.drawImage(img, 0, 0, nsfwCanvas.width, nsfwCanvas.height); - img.src = attachmentPreviewUrl(this.props.id, 'image', width, height); - } - - render() { - const { props } = this; - - const formattedFileSize = formatFileSize(props.fileSize); - const formattedImageSize = `, ${props.width}×${props.height}px`; - const nameAndSize = `${props.fileName} (${formattedFileSize}${formattedImageSize})`; - const alt = `Image attachment ${props.fileName}`; - - const { width, height } = thumbnailSize(this.props); - - const imageAttributes = { - src: attachmentPreviewUrl(props.id, 'image', width, height), - srcSet: `${attachmentPreviewUrl(props.id, 'image', width * 2, height * 2)} 2x`, - alt, - id: props.pictureId, - loading: 'lazy', - width, - height, - }; - - const area = width * height; - const canvasWidth = Math.round(width * Math.sqrt(NSFW_PREVIEW_AREA / area)); - const canvasHeight = Math.round(height * Math.sqrt(NSFW_PREVIEW_AREA / area)); - - return ( -
- - {props.isNSFW && ( - - )} - - - - {props.isEditing && ( - - )} -
- ); - } -} - -export default PostAttachmentImage; diff --git a/src/components/post/post-attachment-like-a-video.jsx b/src/components/post/post-attachment-like-a-video.jsx deleted file mode 100644 index e42356855..000000000 --- a/src/components/post/post-attachment-like-a-video.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { faFileVideo, faPlayCircle } from '@fortawesome/free-regular-svg-icons'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; - -import { useEvent } from 'react-use-event-hook'; -import { formatFileSize } from '../../utils'; -import { ButtonLink } from '../button-link'; -import { Icon } from '../fontawesome-icons'; -import { attachmentPreviewUrl } from '../../services/api'; - -export default function LikeAVideoAttachment({ - id, - fileName, - fileSize, - removeAttachment, - isEditing, -}) { - const url = attachmentPreviewUrl(id, 'original'); - const [isOpen, setIsOpen] = useState(false); - - const handleClickOnRemoveAttachment = useEvent(() => removeAttachment(id)); - const toggleOpen = useEvent(() => setIsOpen(true)); - - const formattedFileSize = formatFileSize(fileSize); - const title = `${fileName} (${formattedFileSize})`; - - const videoRef = useRef(null); - - // Prevent video from playing infinitely (we has this situation once and don't - // want it to happen again) - useEffect(() => { - if (!isOpen || !videoRef.current) { - return; - } - const videoEl = videoRef.current; - - // By default, the video playback should be paused after 5 minutes - let maxPlayTime = 300 * 1000; - let playTimer = 0; - const onPlay = () => { - clearTimeout(playTimer); - playTimer = setTimeout(() => videoEl.pause(), maxPlayTime); - }; - const onPause = () => clearTimeout(playTimer); - const onDurationChange = () => { - // Video in playback mode should not be longer than 10 times of the video duration - maxPlayTime = videoEl.duration * 10 * 1000; - }; - const abortController = new AbortController(); - const { signal } = abortController; - - videoEl.addEventListener('durationchange', onDurationChange, { once: true, signal }); - videoEl.addEventListener('play', onPlay, { signal }); - videoEl.addEventListener('pause', onPause, { signal }); - signal.addEventListener('abort', onPause); - return () => abortController.abort(); - }, [isOpen]); - - return ( -
- {isOpen ? ( -
- -
- ) : ( - - - - )} -
- - - {title} - - - {isEditing && ( - - )} -
-
- ); -} diff --git a/src/components/post/post-attachment-video.jsx b/src/components/post/post-attachment-video.jsx deleted file mode 100644 index 70e22ac79..000000000 --- a/src/components/post/post-attachment-video.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useEvent } from 'react-use-event-hook'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; -import { useEffect, useRef } from 'react'; -import { attachmentPreviewUrl } from '../../services/api'; -import { formatFileSize } from '../../utils'; -import { Icon } from '../fontawesome-icons'; -import { useMediaQuery } from '../hooks/media-query'; -import { videoSize } from './post-attachment-geometry'; - -export function VideoAttachment({ isEditing, removeAttachment, ...att }) { - const handleClickOnRemoveAttachment = useEvent(() => removeAttachment(att.id)); - const title = `Video attachment ${att.fileName} (${formatFileSize(att.fileSize)})`; - const { width, height } = videoSize(att); - const hiDpi = useMediaQuery('(min-resolution: 1.5x)') ? 2 : 1; - - const videoUrl = attachmentPreviewUrl(att.id, 'video', hiDpi * width, hiDpi * height); - - const maxVideoUrl = attachmentPreviewUrl(att.id, 'video'); - - const videoRef = useRef(null); - - useEffect(() => { - const el = videoRef.current; - if (!el) { - return; - } - const h = () => { - const { paused, currentTime } = el; - if (document.fullscreenElement) { - el.src = maxVideoUrl; - } else { - el.src = videoUrl; - } - el.load(); - el.currentTime = currentTime; - if (!paused) { - el.play(); - } - }; - - el.addEventListener('fullscreenchange', h); - return () => el.removeEventListener('fullscreenchange', h); - }, [maxVideoUrl, videoUrl]); - - return ( -
-
- ); -} diff --git a/src/components/post/post-attachments.jsx b/src/components/post/post-attachments.jsx deleted file mode 100644 index 5785898fb..000000000 --- a/src/components/post/post-attachments.jsx +++ /dev/null @@ -1,154 +0,0 @@ -import { shallowEqual, useSelector } from 'react-redux'; -import ErrorBoundary from '../error-boundary'; - -import ImageAttachmentsContainer from './post-attachment-image-container'; -import AudioAttachment from './post-attachment-audio'; -import GeneralAttachment from './post-attachment-general'; -import LikeAVideoAttachment from './post-attachment-like-a-video'; -import { VideoAttachment } from './post-attachment-video'; - -const videoTypes = { - mov: 'video/quicktime', - mp4: 'video/mp4; codecs="avc1.42E01E"', - ogg: 'video/ogg; codecs="theora"', - webm: 'video/webm; codecs="vp8, vorbis"', -}; - -// find video-types which browser supports -let video = document.createElement('video'); -const supportedVideoTypes = Object.entries(videoTypes) - .filter(([, mime]) => video.canPlayType(mime) === 'probably') - .map(([extension]) => extension); - -video = null; - -const looksLikeAVideoFile = (attachment) => { - if (attachment.meta?.inProgress) { - return false; - } - const lowercaseFileName = attachment.fileName.toLowerCase(); - - for (const extension of supportedVideoTypes) { - if (lowercaseFileName.endsWith(`.${extension}`)) { - return true; - } - } - - return false; -}; - -export default function PostAttachments(props) { - const attachments = useSelector( - (state) => (props.attachmentIds || []).map((id) => state.attachments[id]).filter(Boolean), - shallowEqual, - ); - - const imageAttachments = []; - const audioAttachments = []; - const videoAttachments = []; - const likeAVideoAttachments = []; - const generalAttachments = []; - - attachments.forEach((attachment) => { - if (attachment.mediaType === 'image') { - imageAttachments.push(attachment); - } else if (attachment.mediaType === 'audio') { - audioAttachments.push(attachment); - } else if (attachment.mediaType === 'video' && !attachment.meta?.inProgress) { - videoAttachments.push(attachment); - } else if (attachment.mediaType === 'general' && looksLikeAVideoFile(attachment)) { - likeAVideoAttachments.push(attachment); - } else { - generalAttachments.push(attachment); - } - }); - - const imageAttachmentsContainer = - imageAttachments.length > 0 ? ( - - ) : ( - false - ); - - const audioAttachmentsNodes = audioAttachments.map((attachment) => ( - - )); - const audioAttachmentsContainer = - audioAttachments.length > 0 ? ( -
{audioAttachmentsNodes}
- ) : ( - false - ); - - const likeAVideoAttachmentsNodes = likeAVideoAttachments.map((attachment) => ( - - )); - const likeVideoAttachmentsContainer = - likeAVideoAttachments.length > 0 ? ( -
{likeAVideoAttachmentsNodes}
- ) : ( - false - ); - - const videoAttachmentsNodes = videoAttachments.map((attachment) => ( - - )); - const videoAttachmentsContainer = - videoAttachments.length > 0 ? ( -
{videoAttachmentsNodes}
- ) : ( - false - ); - - const generalAttachmentsNodes = generalAttachments.map((attachment) => ( - - )); - const generalAttachmentsContainer = - generalAttachments.length > 0 ? ( -
{generalAttachmentsNodes}
- ) : ( - false - ); - - return attachments.length > 0 ? ( -
- - {imageAttachmentsContainer} - {audioAttachmentsContainer} - {videoAttachmentsContainer} - {likeVideoAttachmentsContainer} - {generalAttachmentsContainer} - -
- ) : ( - false - ); -} diff --git a/styles/helvetica/app.scss b/styles/helvetica/app.scss index 51ae4e545..acecd5126 100644 --- a/styles/helvetica/app.scss +++ b/styles/helvetica/app.scss @@ -6,8 +6,6 @@ @import '../shared/forms'; @import '../shared/post'; @import 'boxes'; -@import '../shared/attachments'; -@import '../shared/attachments-edit'; @import '../shared/likes'; @import '../shared/comments'; @import 'user'; diff --git a/styles/shared/attachments-edit.scss b/styles/shared/attachments-edit.scss deleted file mode 100644 index fa797298e..000000000 --- a/styles/shared/attachments-edit.scss +++ /dev/null @@ -1,98 +0,0 @@ -// Editing entries - -.image-attachments { - .attachment .remove-attachment { - display: block; - position: absolute; - right: 2px; - top: 2px; - cursor: pointer; - color: #000; - font-size: 20px; - background-color: silver; - opacity: 0.7; - border-radius: 0.1em; - border-bottom-left-radius: 0.25em; - width: 1.5em; - height: 1.5em; - line-height: 1.4em; - padding: 4px; - - &:hover { - opacity: 1; - } - } - - .attachment.removed { - border: 2px solid #e33; - padding: 0; - } - - .attachment.added { - border: 2px solid #3d3; - padding: 0; - } - - .show-more { - display: none; - width: 24px; - color: #8ab; - vertical-align: middle; - margin-bottom: 8px; - - .show-more-icon { - cursor: pointer; - transform: rotate(-180deg); - transition: transform 0.3s 0.1s; - font-size: 1.75em; - } - } - - &.needs-folding { - .show-more { - display: inline-block; - } - } - - &.is-folded .show-more .show-more-icon { - transform: rotate(0); - } - - .lightbox-loading { - background: rgba(0, 0, 0, 0.8); - color: #ccc; - position: fixed; - inset: 0; - z-index: 10; - display: flex; - justify-content: center; - align-items: center; - } -} - -.audio-attachments, -.video-attachments, -.general-attachments { - .attachment:hover .remove-attachment { - display: block; - position: absolute; - top: 1px; - width: 1em; - height: 1em; - cursor: pointer; - background-color: #fff; - border-radius: 2px; - - &:hover { - background-color: #ddd; - } - } - - .attachment.removed { - background-color: #fbb; - } - - .attachment.added { - background-color: #bfb; - } -} diff --git a/styles/shared/attachments.scss b/styles/shared/attachments.scss deleted file mode 100644 index a9f8cf431..000000000 --- a/styles/shared/attachments.scss +++ /dev/null @@ -1,176 +0,0 @@ -// General - -.attachments { - margin-bottom: 2px; - - a { - color: #000088; - text-decoration: none; - - &:hover span { - text-decoration: underline; - } - } - - .remove-attachment { - display: none; - } - - // Clearfix (http://www.cssmojo.com/latest_new_clearfix_so_far/) - &::after { - content: ''; - display: table; - clear: both; - } -} - -.image-attachments { - .attachment { - position: relative; - display: inline-block; - vertical-align: middle; - margin: 0 8px 8px 0; - box-sizing: content-box; - text-align: center; - - &.surplus { - display: none; - } - } - - .image-attachment-img { - max-width: 525px; - max-height: 175px; - background-color: #fff; - - @media (max-width: 560px) { - max-width: 100%; - height: auto; - } - } - - .image-attachment-link { - display: block; - min-width: 32px; - min-height: 32px; - line-height: 30px; - cursor: zoom-in; - border: 1px solid silver; - padding: 1px; - - &:hover { - border-color: #aaa; - } - - .nsfw-post & { - position: relative; - overflow: hidden; - - &::after { - content: 'NSFW'; - color: #ccc; - background-color: rgba(0, 0, 0, 0.2); - font-weight: bold; - pointer-events: none; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - } - } - } - - .image-attachment-nsfw-canvas { - position: absolute; - width: 100%; - height: 100%; - filter: contrast(1.2); - } - - &.single-image .attachment { - height: auto; - line-height: normal; - } - - &.expanded .attachment.surplus { - display: inline-block; - } - - .toggle-surplus { - display: none; - color: #fad889; // logotype yellow (#f9b616) with lower saturation; - font-size: 2em; - text-decoration: none; - position: relative; - top: 2px; - } - - &.has-surplus .toggle-surplus { - display: inline-block; - - // fa-chevron-circle-right - &::before { - content: '\f138'; - } - } - - &.has-surplus.expanded .toggle-surplus { - // fa-chevron-circle-left - &::before { - content: '\f137'; - } - } -} - -.sortable-images .image-attachment-link { - cursor: move; -} - -.audio-attachments, -.video-attachments, -.general-attachments { - .attachment { - position: relative; - display: block; - margin: 0 8px 8px 0; - - .attachment-icon { - color: #666666; - padding: 0 1px; - margin-right: 4px; - } - - .attachment-title { - overflow-wrap: break-word; - } - } -} - -.video-attachments { - .video-attachment-click-to-play { - display: flex; - width: 3em; - height: 3em; - justify-content: center; - align-items: center; - border-radius: 2px; - background-color: #eee; - font-size: 2em; - } - - .attachment--video { - display: inline-flex; - border: 1px solid silver; - padding: 1px; - } - - video { - max-width: 100%; - height: auto; - background-color: #eee; - } -} From 874cfebe05d28e551265fb91753f00c9b323e930 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Thu, 6 Feb 2025 22:25:44 +0300 Subject: [PATCH 32/55] Disable PiP for videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PiP element may stay active/playing after closing. We need a separate UI for the PiP --- src/components/post/attachments/like-a-video.jsx | 1 + src/components/post/attachments/visual/attachment.jsx | 1 + src/services/lightbox-actual.js | 1 + test/jest/__snapshots__/post-attachments.test.jsx.snap | 1 + 4 files changed, 4 insertions(+) diff --git a/src/components/post/attachments/like-a-video.jsx b/src/components/post/attachments/like-a-video.jsx index a89940d9e..8d78c0a5e 100644 --- a/src/components/post/attachments/like-a-video.jsx +++ b/src/components/post/attachments/like-a-video.jsx @@ -24,6 +24,7 @@ export function LikeAVideo({ attachment: att }) { title={att.fileName} autoPlay controls + disablePictureInPicture src={attachmentPreviewUrl(att.id, 'original')} />
diff --git a/src/components/post/attachments/visual/attachment.jsx b/src/components/post/attachments/visual/attachment.jsx index d0b54c9f3..ec31b5978 100644 --- a/src/components/post/attachments/visual/attachment.jsx +++ b/src/components/post/attachments/visual/attachment.jsx @@ -112,6 +112,7 @@ export function VisualAttachment({ muted loop playsInline + disablePictureInPicture onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onTimeUpdate={handleTimeUpdate} diff --git a/src/services/lightbox-actual.js b/src/services/lightbox-actual.js index 63ebcc977..26dd3cc5e 100644 --- a/src/services/lightbox-actual.js +++ b/src/services/lightbox-actual.js @@ -189,6 +189,7 @@ function initLightbox() { return; } if (data.type === 'video') { + element.disablePictureInPicture = true; if (data.meta.animatedImage || (data.meta.silent && data.duration <= 5)) { element.muted = true; element.loop = true; diff --git a/test/jest/__snapshots__/post-attachments.test.jsx.snap b/test/jest/__snapshots__/post-attachments.test.jsx.snap index 4dac3c5b2..924d358e0 100644 --- a/test/jest/__snapshots__/post-attachments.test.jsx.snap +++ b/test/jest/__snapshots__/post-attachments.test.jsx.snap @@ -38,6 +38,7 @@ exports[`PostAttachments > Displays all post attachment types 1`] = `
@@ -193,7 +193,7 @@ exports[`PostAttachments > Displays all post attachment types 1`] = ` > Date: Sat, 8 Feb 2025 20:17:27 +0300 Subject: [PATCH 36/55] Rewrite the mediaOpener/MediaLink functionaity --- src/components/linkify-elements.jsx | 24 +-- src/components/linkify.jsx | 5 +- src/components/media-links/helpers.jsx | 198 ++++++++++++++++++ src/components/media-links/media-link.jsx | 46 +++++ src/components/media-links/provider.jsx | 24 +++ src/components/media-opener.jsx | 233 ---------------------- styles/shared/lighbox.scss | 1 + test/unit/components/piece-of-text.jsx | 4 +- 8 files changed, 280 insertions(+), 255 deletions(-) create mode 100644 src/components/media-links/helpers.jsx create mode 100644 src/components/media-links/media-link.jsx create mode 100644 src/components/media-links/provider.jsx delete mode 100644 src/components/media-opener.jsx diff --git a/src/components/linkify-elements.jsx b/src/components/linkify-elements.jsx index 83e07dd43..2a25ae543 100644 --- a/src/components/linkify-elements.jsx +++ b/src/components/linkify-elements.jsx @@ -20,10 +20,10 @@ import { } from '../utils/parse-text'; import { INITIAL_CHECKBOX, isChecked } from '../utils/initial-checkbox'; import UserName from './user-name'; -import { MediaOpener, getMediaType } from './media-opener'; import { InitialCheckbox } from './initial-checkbox'; import { Anchor, Link } from './linkify-links'; import CodeBlock from './code-block'; +import { MediaLink } from './media-links/media-link'; const { searchEngine } = CONFIG.search; const MAX_URL_LENGTH = 50; @@ -89,7 +89,7 @@ export function tokenToElement(token, key, params) { } case LINK: - return renderLink(token, key, params); + return renderLink(token, key); case SHORT_LINK: return ( @@ -159,7 +159,7 @@ export function tokenToElement(token, key, params) { return token.text; } -function renderLink(token, key, params) { +function renderLink(token, key) { const href = linkHref(token.text); if (isLocalLink(token.text)) { @@ -188,23 +188,9 @@ function renderLink(token, key, params) { ); } - const mediaType = getMediaType(href); - if (mediaType) { - return ( - - {prettyLink(token.text, MAX_URL_LENGTH)} - - ); - } - return ( - + {prettyLink(token.text, MAX_URL_LENGTH)} - + ); } diff --git a/src/components/linkify.jsx b/src/components/linkify.jsx index 10d80f3e9..4d8a3f73f 100644 --- a/src/components/linkify.jsx +++ b/src/components/linkify.jsx @@ -9,6 +9,7 @@ import ErrorBoundary from './error-boundary'; import { tokenToElement } from './linkify-elements'; import Spoiler from './spoiler'; import UserName from './user-name'; +import { MediaLinksProvider } from './media-links/provider'; export default function Linkify({ children, @@ -36,7 +37,9 @@ export default function Linkify({ return ( - {formatted} + + {formatted} + ); } diff --git a/src/components/media-links/helpers.jsx b/src/components/media-links/helpers.jsx new file mode 100644 index 000000000..5c0c3c0dc --- /dev/null +++ b/src/components/media-links/helpers.jsx @@ -0,0 +1,198 @@ +import { renderToString } from 'react-dom/server'; +import { createContext, useContext, useMemo } from 'react'; +import { + canShowURL as isInstagram, + getEmbedInfo as getInstagramEmbedInfo, +} from '../link-preview/instagram'; +import { getVideoInfo, getVideoType, T_YOUTUBE_VIDEO } from '../link-preview/video'; + +export const mediaLinksContext = createContext(() => () => {}); + +export function useMediaLink(url) { + const registerLink = useContext(mediaLinksContext); + const mediaType = useMemo(() => getMediaType(url), [url]); + // Register link on component mount + const openLink = useMemo(() => registerLink(url, mediaType), [mediaType, registerLink, url]); + return [mediaType, openLink]; +} + +export const IMAGE = 'image'; +export const VIDEO = 'video'; +export const INSTAGRAM = 'instagram'; + +export function getMediaType(url) { + try { + const urlObj = new URL(url); + if (urlObj.pathname.match(/\.(jpg|png|jpeg|webp|gif)$/i)) { + return IMAGE; + } else if (urlObj.pathname.match(/\.mp4$/i)) { + return VIDEO; + } else if (isInstagram(url)) { + return INSTAGRAM; + } + return getVideoType(url); + } catch { + // For some URLs in user input, the 'new URL' may throw error. Just return + // null (unknown type) in this case. + return null; + } +} + +export const stubItem = { + type: 'html', + html: renderToString( +
+
Loading...
+
, + ), +}; + +export function createErrorItem(error) { + return { + type: 'html', + html: renderToString( +
+
${error.message}
+
, + ), + }; +} + +export async function createLightboxItem(url, mediaType) { + switch (mediaType) { + case IMAGE: + return { + // Convert dropbox page URL to image URL + src: url.replace('https://www.dropbox.com/s/', 'https://dl.dropboxusercontent.com/s/'), + type: 'image', + width: 1, + height: 1, + autoSize: true, + }; + case VIDEO: + return { + videoSrc: url, + // Empty image for placeholder + msrc: 'data:image/svg+xml,', + type: 'video', + width: 1, + height: 1, + autoSize: true, + meta: {}, + }; + default: + return await getEmbeddableItem(url, mediaType); + } +} + +async function getEmbeddableItem(url, mediaType) { + let info = null; + if (isInstagram(url)) { + info = getInstagramEmbedInfo(url); + } else { + info = await getVideoInfo(url); + } + + if (!info) { + throw new Error("Can't get embed info"); + } else if (info.error) { + throw new Error(info.error); + } + + if (info.mediaURL) { + return { + src: info.mediaURL, + width: info.width || 1, + height: info.height || 1, + autoSize: !info.width || !info.height, + type: 'image', + }; + } + + let width = 900; + let height = 506; + if (info.aspectRatio) { + if (info.aspectRatio <= 1) { + height = Math.round(width * info.aspectRatio); + } else { + height = 800; + width = Math.round(height / info.aspectRatio); + } + } + + let playerHTML = null; + if (info.html) { + playerHTML = info.html; + } else if (info.playerURL) { + playerHTML = renderToString( +