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..77c3501f8
--- /dev/null
+++ b/src/components/media-links/helpers.jsx
@@ -0,0 +1,289 @@
+/* global CONFIG */
+import { renderToString } from 'react-dom/server';
+import { createContext, useContext, useMemo, useState } from 'react';
+import { useEvent } from 'react-use-event-hook';
+import {
+ canShowURL as isInstagram,
+ getEmbedInfo as getInstagramEmbedInfo,
+} from '../link-preview/instagram';
+import { getVideoInfo, getVideoType, T_VIMEO_VIDEO, T_YOUTUBE_VIDEO } from '../link-preview/video';
+import { isLeftClick } from '../../utils';
+import { openLightbox } from '../../services/lightbox';
+import { attachmentPreviewUrl } from '../../services/api';
+import { getAttachmentInfo } from '../../services/batch-attachments-info';
+import { pauseYoutubeVideo, playYoutubeVideo } from './youtube-api';
+import { pauseVimeoVideo, playVimeoVideo } from './vimeo-api';
+
+export const mediaLinksContext = createContext([]);
+
+export function useMediaLink(url) {
+ const items = useContext(mediaLinksContext);
+ const [mediaType, setMediaType] = useState(() => getMediaType(url));
+ const index = useMemo(() => {
+ const index = items.length;
+ items.push(stubItem);
+ createLightboxItem(url)
+ .then((item) => {
+ if (!item) {
+ setMediaType(null);
+ } else if (item.mediaType) {
+ setMediaType(item.mediaType);
+ }
+ items[index] = item;
+ return null;
+ })
+ .catch((err) => (items[index] = createErrorItem(err)));
+ return index;
+ // Items are reference-immutable, url is truly immutable
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleClick = useEvent((e) => {
+ if (!mediaType || !isLeftClick(e)) {
+ return;
+ }
+ e.preventDefault();
+
+ // Remove empty items and modify index
+ const nonEmptyItems = [];
+ let newIndex = 0;
+ for (const [i, item] of items.entries()) {
+ if (item) {
+ nonEmptyItems.push(item);
+ if (i === index) {
+ newIndex = nonEmptyItems.length - 1;
+ }
+ }
+ }
+
+ openLightbox(newIndex, nonEmptyItems);
+ });
+ return [mediaType, handleClick];
+}
+
+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(
+ ,
+ ),
+};
+
+export function createErrorItem(error) {
+ return {
+ type: 'html',
+ html: renderToString(
+ ,
+ ),
+ };
+}
+
+const freefeedPathRegex = /^\/attachments\/(?:\w+\/)?([\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12})/;
+
+function freefeedAttachmentId(url) {
+ try {
+ const urlObj = new URL(url);
+ if (!CONFIG.attachmentDomains.includes(urlObj.hostname)) {
+ return null;
+ }
+ const [, id] = freefeedPathRegex.exec(urlObj.pathname) ?? [null, null];
+ return id;
+ } catch {
+ return null;
+ }
+}
+
+async function createLightboxItem(url) {
+ const attId = freefeedAttachmentId(url);
+ if (attId) {
+ // Freefeed attachment
+ const att = await getAttachmentInfo(attId);
+ if (!att) {
+ return null;
+ }
+ if (att.meta?.inProgress) {
+ // Retry after 5 seconds
+ return new Promise((resolve) => setTimeout(() => resolve(createLightboxItem(url)), 5000));
+ } else if (att.mediaType === 'image') {
+ return {
+ type: IMAGE,
+ mediaType: 'image',
+ src: attachmentPreviewUrl(att.id, 'image'),
+ originalSrc: attachmentPreviewUrl(att.id, 'original'),
+ width: att.previewWidth ?? att.width,
+ height: att.previewHeight ?? att.height,
+ };
+ } else if (att.mediaType === 'video') {
+ return {
+ type: VIDEO,
+ mediaType: 'video',
+ videoSrc: attachmentPreviewUrl(att.id, 'video'),
+ msrc: 'data:image/svg+xml,',
+ originalSrc: attachmentPreviewUrl(att.id, 'original'),
+ width: att.previewWidth ?? att.width,
+ height: att.previewHeight ?? att.height,
+ meta: att.meta ?? {},
+ duration: att.duration ?? 0,
+ };
+ }
+ return null;
+ }
+
+ const mediaType = getMediaType(url);
+ if (!mediaType) {
+ return null;
+ }
+ 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, true);
+ }
+
+ 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(
+ ,
+ );
+ } else if (info.videoURL) {
+ playerHTML = renderToString(
+ ,
+ );
+ }
+
+ let onActivate = null;
+ let onDeactivate = null;
+
+ if (info.videoURL) {
+ // Simple HTML5 video element play/pause
+ onActivate = (element) => element.querySelector('video').play();
+ onDeactivate = (element) => element.querySelector('video').pause();
+ }
+ if (mediaType === T_YOUTUBE_VIDEO) {
+ onActivate = (element) => playYoutubeVideo(element.querySelector('iframe'));
+ onDeactivate = (element) => pauseYoutubeVideo(element.querySelector('iframe'));
+ }
+ if (mediaType === T_VIMEO_VIDEO) {
+ onActivate = (element) => playVimeoVideo(element.querySelector('iframe'));
+ onDeactivate = (element) => pauseVimeoVideo(element.querySelector('iframe'));
+ }
+
+ let text = info.byline;
+ if (text.length > 300) {
+ text = `${text.slice(0, 200)}\u2026`;
+ }
+ const titleHTML = renderToString(
+
+ {text || url}
+ ,
+ );
+ return {
+ type: 'html',
+ html: `
`,
+ width,
+ height,
+ mediaType,
+ onActivate,
+ onDeactivate,
+ };
+}
diff --git a/src/components/media-links/media-link.jsx b/src/components/media-links/media-link.jsx
new file mode 100644
index 000000000..8bbfd8a61
--- /dev/null
+++ b/src/components/media-links/media-link.jsx
@@ -0,0 +1,38 @@
+import cn from 'classnames';
+import { faInstagram, faVimeo, faYoutube } from '@fortawesome/free-brands-svg-icons';
+import { faImage } from '@fortawesome/free-regular-svg-icons';
+import { faFilm } from '@fortawesome/free-solid-svg-icons';
+import { T_VIMEO_VIDEO, T_YOUTUBE_VIDEO } from '../link-preview/video';
+import { Icon } from '../fontawesome-icons';
+import { IMAGE, INSTAGRAM, useMediaLink, VIDEO } from './helpers';
+
+export function MediaLink({ href: url, children }) {
+ const [mediaType, handleClick] = useMediaLink(url);
+
+ const mediaIcon = {
+ [INSTAGRAM]: faInstagram,
+ [T_YOUTUBE_VIDEO]: faYoutube,
+ [T_VIMEO_VIDEO]: faVimeo,
+ [IMAGE]: faImage,
+ [VIDEO]: faFilm,
+ }[mediaType];
+
+ const mediaProps = mediaIcon
+ ? {
+ onClick: handleClick,
+ className: cn('media-link', mediaType),
+ title: 'Click to view in Lightbox',
+ }
+ : {};
+
+ return (
+
+ {mediaIcon && (
+
+
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/components/media-links/provider.jsx b/src/components/media-links/provider.jsx
new file mode 100644
index 000000000..890cb6aa6
--- /dev/null
+++ b/src/components/media-links/provider.jsx
@@ -0,0 +1,7 @@
+import { useMemo } from 'react';
+import { mediaLinksContext as ctx } from './helpers';
+
+export function MediaLinksProvider({ children }) {
+ const list = useMemo(() => [], []);
+ return
{children};
+}
diff --git a/src/components/media-links/vimeo-api.js b/src/components/media-links/vimeo-api.js
new file mode 100644
index 000000000..a2394d412
--- /dev/null
+++ b/src/components/media-links/vimeo-api.js
@@ -0,0 +1,47 @@
+const readyFrames = new WeakSet();
+
+export async function playVimeoVideo(iframe) {
+ await waitForReady(iframe);
+ sendCommand(iframe, 'play');
+}
+
+export function pauseVimeoVideo(iframe) {
+ sendCommand(iframe, 'pause');
+}
+
+function waitForReady(iframe) {
+ if (readyFrames.has(iframe)) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ const abortController = new AbortController();
+ window.addEventListener(
+ 'message',
+ ({ source, data }) => {
+ if (source === iframe.contentWindow) {
+ data = JSON.parse(data);
+ if (data?.event === 'ready') {
+ if (iframe.isConnected) {
+ readyFrames.add(iframe);
+ resolve();
+ } else {
+ reject(new Error('Vimeo iframe disconnected'));
+ }
+ abortController.abort();
+ }
+ }
+ },
+ { signal: abortController.signal },
+ );
+ });
+}
+
+function sendCommand(iframe, command) {
+ if (readyFrames.has(iframe)) {
+ send(iframe, { method: command });
+ }
+}
+
+function send(iframe, data) {
+ iframe.contentWindow?.postMessage(JSON.stringify(data), 'https://player.vimeo.com');
+}
diff --git a/src/components/media-links/youtube-api.js b/src/components/media-links/youtube-api.js
new file mode 100644
index 000000000..c68cbc4fd
--- /dev/null
+++ b/src/components/media-links/youtube-api.js
@@ -0,0 +1,60 @@
+const readyFrames = new WeakSet();
+
+export async function playYoutubeVideo(iframe) {
+ await waitForReady(iframe);
+ sendCommand(iframe, 'playVideo');
+}
+
+export function pauseYoutubeVideo(iframe) {
+ sendCommand(iframe, 'pauseVideo');
+}
+
+function waitForReady(iframe) {
+ if (readyFrames.has(iframe)) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ const timer = setInterval(() => send(iframe, { event: 'listening' }), 250);
+
+ const abortController = new AbortController();
+ setTimeout(() => {
+ // Force abort after 10 seconds
+ abortController.abort();
+ clearInterval(timer);
+ reject(new Error('Timeout'));
+ }, 10_000);
+ window.addEventListener(
+ 'message',
+ ({ source, data }) => {
+ if (source === iframe.contentWindow) {
+ data = JSON.parse(data);
+ switch (data?.event) {
+ case 'initialDelivery': {
+ // The player has heard our messages
+ clearInterval(timer);
+ break;
+ }
+ case 'onReady': {
+ // The player is ready to receive commands
+ readyFrames.add(iframe);
+ abortController.abort();
+ resolve();
+ break;
+ }
+ }
+ }
+ },
+ { signal: abortController.signal },
+ );
+ });
+}
+
+function sendCommand(iframe, command) {
+ if (readyFrames.has(iframe)) {
+ send(iframe, { event: 'command', func: command });
+ }
+}
+
+function send(iframe, data) {
+ iframe.contentWindow?.postMessage(JSON.stringify(data), 'https://www.youtube.com');
+}
diff --git a/src/components/media-opener.jsx b/src/components/media-opener.jsx
deleted file mode 100644
index e35c6c2da..000000000
--- a/src/components/media-opener.jsx
+++ /dev/null
@@ -1,220 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import { faImage } from '@fortawesome/free-regular-svg-icons';
-import { faFilm as faVideo } from '@fortawesome/free-solid-svg-icons';
-import { faInstagram, faYoutube, faVimeo } from '@fortawesome/free-brands-svg-icons';
-import cn from 'classnames';
-import { renderToString } from 'react-dom/server';
-import { openLightbox } from '../services/lightbox';
-import { Icon } from './fontawesome-icons';
-import {
- canShowURL as isInstagram,
- getEmbedInfo as getInstagramEmbedInfo,
-} from './link-preview/instagram';
-import { T_YOUTUBE_VIDEO, getVideoInfo, getVideoType } from './link-preview/video';
-
-export const getMediaType = (url) => {
- try {
- if (new URL(url).pathname.match(/\.(jpg|png|jpeg|webp|gif)$/i)) {
- return 'image';
- } 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 function MediaOpener({ url, mediaType, attachmentsRef, children }) {
- const media = useMemo(() => {
- const m = { url, mediaType };
- attachmentsRef.current.push(m);
- return m;
- }, [attachmentsRef, mediaType, url]);
-
- const openMedia = useCallback(
- (e) => {
- if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
- return;
- }
- e.preventDefault();
- (async () => {
- const index = attachmentsRef.current.indexOf(media);
- openLightbox(
- index,
- await Promise.all(
- attachmentsRef.current.map(async ({ url, mediaType }, idx) => {
- if (mediaType === '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,
- };
- }
- return await getEmbeddableItem(url, mediaType, idx === index);
- }),
- ),
- );
- })();
- },
- [attachmentsRef, media],
- );
-
- const mediaIcon =
- {
- instagram: faInstagram,
- T_YOUTUBE_VIDEO: faYoutube,
- T_VIMEO_VIDEO: faVimeo,
- image: faImage,
- }[mediaType] || faVideo;
-
- return (
-
-
-
-
- {children}
-
- );
-}
-
-async function getEmbeddableItem(url, mediaType, isActiveSlide) {
- let info = null;
- if (isInstagram(url)) {
- info = getInstagramEmbedInfo(url);
- } else {
- // Autoplay Youtube video on active slide
- info = await getVideoInfo(url, !(isActiveSlide && mediaType === T_YOUTUBE_VIDEO));
- }
-
- if (info.error) {
- return {
- type: 'html',
- html: `
`,
- };
- }
-
- if (info) {
- if (info.mediaURL) {
- return {
- src: info.mediaURL,
- width: info.width || 0,
- height: info.height || 0,
- type: 'image',
- };
- }
-
- let playerHTML = null;
- 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);
- }
- }
-
- if (info.html) {
- playerHTML = info.html;
- } else {
- let player = null;
- if (info.playerURL) {
- player = (
-
- );
- } else if (info.videoURL) {
- player = (
-
- );
- }
-
- if (player) {
- playerHTML = renderToString(player);
- }
- }
-
- let onActivate = null;
- let onDeactivate = null;
-
- if (info.videoURL) {
- // Simple HTML5 video element play/pause
- onActivate = (element) => element.querySelector('video').play();
- onDeactivate = (element) => element.querySelector('video').pause();
- }
- if (mediaType === T_YOUTUBE_VIDEO) {
- onActivate = function (element) {
- const iframe = element.querySelector('iframe');
- iframe.contentWindow?.postMessage(
- JSON.stringify({ event: 'command', func: 'playVideo' }),
- 'https://www.youtube.com',
- );
- };
- onDeactivate = function (element) {
- const iframe = element.querySelector('iframe');
- iframe.contentWindow?.postMessage(
- JSON.stringify({ event: 'command', func: 'pauseVideo' }),
- 'https://www.youtube.com',
- );
- };
- }
-
- if (playerHTML) {
- let text = info.byline;
- if (text.length > 300) {
- text = `${text.slice(0, 200)}\u2026`;
- }
- const titleHTML = renderToString(
-
- {text || url}
- ,
- );
- return {
- type: 'html',
- html: `
`,
- width,
- height,
- mediaType,
- onActivate,
- onDeactivate,
- };
- }
- }
-}
diff --git a/src/components/post/attachments/attachments.jsx b/src/components/post/attachments/attachments.jsx
new file mode 100644
index 000000000..3602e382e
--- /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') {
+ 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..adaebb53d
--- /dev/null
+++ b/src/components/post/attachments/attachments.module.scss
@@ -0,0 +1,84 @@
+@import '../../../../styles/helvetica/dark-vars.scss';
+
+.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;
+}
+
+.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 {
+ word-break: break-word;
+}
+
+.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);
+}
+
+.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/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 (
+
+ );
+}
diff --git a/src/components/post/attachments/general.jsx b/src/components/post/attachments/general.jsx
new file mode 100644
index 000000000..6bc786c84
--- /dev/null
+++ b/src/components/post/attachments/general.jsx
@@ -0,0 +1,41 @@
+import cn from 'classnames';
+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 nameAndSize = `${att.fileName} (${formatFileSize(att.fileSize)})`;
+ const extension = att.fileName.split('.').pop().toLowerCase();
+
+ return (
+
+ {supportedVideoTypes.includes(extension) && }
+
+
+ );
+}
diff --git a/src/components/post/attachments/like-a-video.jsx b/src/components/post/attachments/like-a-video.jsx
new file mode 100644
index 000000000..8d78c0a5e
--- /dev/null
+++ b/src/components/post/attachments/like-a-video.jsx
@@ -0,0 +1,45 @@
+import { useRef, useState } from 'react';
+import { useEvent } from 'react-use-event-hook';
+import { faPlayCircle } from '@fortawesome/free-solid-svg-icons';
+import { attachmentPreviewUrl } from '../../../services/api';
+import { Icon } from '../../fontawesome-icons';
+import style from './attachments.module.scss';
+import { useStopVideo } from './visual/hooks';
+
+export function LikeAVideo({ attachment: att }) {
+ const [isOpened, setIsOpened] = useState(false);
+
+ const handleOpen = useEvent(() => setIsOpened(true));
+
+ const videoRef = useRef(null);
+
+ useStopVideo(videoRef, isOpened);
+
+ if (isOpened) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
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 (
+
+
+
+ {removeAttachment && (
+
+ )}
+
+ );
+}
diff --git a/src/components/post/attachments/visual/attachment.jsx b/src/components/post/attachments/visual/attachment.jsx
new file mode 100644
index 000000000..8c98a21aa
--- /dev/null
+++ b/src/components/post/attachments/visual/attachment.jsx
@@ -0,0 +1,216 @@
+import cn from 'classnames';
+import { useEvent } from 'react-use-event-hook';
+import { faPlay, faSpinner, faTimes } from '@fortawesome/free-solid-svg-icons';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { attachmentPreviewUrl } from '../../../../services/api';
+import { formatFileSize } from '../../../../utils';
+import { Icon } from '../../../fontawesome-icons';
+import { usePixelRatio } from '../../../hooks/pixel-ratio';
+import { useScreenWidth } from '../../../hooks/screen-width';
+import style from './visual.module.scss';
+import { NsfwCanvas } from './nsfw-canvas';
+import { fitIntoBox } from './geometry';
+import { useStopVideo } from './hooks';
+
+export function VisualAttachment({
+ attachment: att,
+ pictureId,
+ width,
+ height,
+ handleClick: givenClickHandler,
+ 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: mediaWidth, height: mediaHeight } = fitIntoBox(att, width, height);
+
+ // Don't update preview URLs if the size hasn't changed by more than the minimum size difference
+ const [prvWidth, prvHeight] = useDampedSize(mediaWidth, mediaHeight);
+
+ const pixRatio = usePixelRatio();
+
+ const { inlinePlaying, isGifLike } = useVideoProps(att, isNSFW, mediaWidth, mediaHeight);
+
+ const handleMouseEnter = useEvent((e) => {
+ if (!inlinePlaying && window.matchMedia?.('(hover: hover)').matches) {
+ e.target.play();
+ }
+ });
+ const handleMouseLeave = useEvent((e) => {
+ if (!inlinePlaying && window.matchMedia?.('(hover: hover)').matches) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ });
+
+ const videoRef = useRef(null);
+ useStopVideo(videoRef, att.mediaType === 'video' && !att.meta?.inProgress);
+ const { videoPlaying, currentTime } = useVideoEvents(videoRef);
+
+ const handleClick = useEvent((e) => {
+ if (inlinePlaying) {
+ if (videoPlaying) {
+ videoRef.current.pause();
+ } else {
+ videoRef.current.play();
+ }
+ e.preventDefault();
+ } else {
+ givenClickHandler(e);
+ }
+ });
+
+ const handleRemove = useEvent((e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ removeAttachment?.(att.id);
+ });
+
+ const imageSrc = attachmentPreviewUrl(att.id, 'image', pixRatio * prvWidth, pixRatio * prvHeight);
+ const videoSrc = attachmentPreviewUrl(att.id, 'video', pixRatio * prvWidth, pixRatio * prvHeight);
+
+ return (
+
+ {att.meta?.inProgress ? (
+
+
+ processing
+
+ ) : (
+ <>
+ {/**
+ * This image is used for the proper lightbox opening animation,
+ * even if the attachment has 'video' type.
+ */}
+
+ {att.mediaType === 'video' && !isNSFW && (
+
+ )}
+ {isNSFW && !removeAttachment && (
+
+ )}
+ {inlinePlaying && !videoPlaying && }
+ >
+ )}
+ {att.mediaType === 'video' && !inlinePlaying && (
+
+ {att.meta?.animatedImage ? GIF : }
+ {formatTime(att.duration - currentTime)}
+
+ )}
+ {removeAttachment && (
+
+ )}
+
+ );
+}
+
+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')}`;
+}
+
+function useVideoProps(att, isNSFW, width, height) {
+ const isGifLike =
+ att.mediaType === 'video' &&
+ (att.meta?.animatedImage || (att.meta?.silent && att.duration <= 5));
+
+ const screenWidth = useScreenWidth();
+ const inlinePlaying =
+ att.mediaType === 'video' &&
+ !isGifLike &&
+ !isNSFW &&
+ (width > 0.75 * screenWidth || width * height > 100000);
+
+ return { inlinePlaying, isGifLike };
+}
+
+function useVideoEvents(videoRef) {
+ const [currentTime, setCurrentTime] = useState(0);
+ const [videoPlaying, setVideoPlaying] = useState(false);
+ useEffect(() => {
+ const el = videoRef.current;
+ if (!el) {
+ return;
+ }
+ const abortController = new AbortController();
+ const { signal } = abortController;
+ el.addEventListener('timeupdate', (e) => setCurrentTime(Math.floor(e.target.currentTime)), {
+ signal,
+ });
+ el.addEventListener('pause', () => setVideoPlaying(false), { signal });
+ el.addEventListener('play', () => setVideoPlaying(true), { signal });
+ el.addEventListener('ended', () => (el.currentTime = 0), { signal });
+
+ return () => abortController.abort();
+ }, [videoRef]);
+ return { currentTime, videoPlaying };
+}
+
+function useDampedSize(mediaWidth, mediaHeight, minDifference = 40) {
+ const [prvWidth, setPrvWidth] = useState(mediaWidth);
+ const [prvHeight, setPrvHeight] = useState(mediaHeight);
+
+ useLayoutEffect(() => {
+ // Don't update preview URLs if the size hasn't changed by more than the minimum size difference
+ if (
+ Math.abs(mediaWidth - prvWidth) < minDifference &&
+ Math.abs(mediaHeight - prvHeight) < minDifference
+ ) {
+ return;
+ }
+ setPrvWidth(mediaWidth);
+ setPrvHeight(mediaHeight);
+ }, [prvWidth, prvHeight, mediaWidth, mediaHeight, minDifference]);
+
+ return [prvWidth, prvHeight];
+}
diff --git a/src/components/post/attachments/visual/container-editable.jsx b/src/components/post/attachments/visual/container-editable.jsx
new file mode 100644
index 000000000..04fc1fcd9
--- /dev/null
+++ b/src/components/post/attachments/visual/container-editable.jsx
@@ -0,0 +1,76 @@
+import cn from 'classnames';
+import { useEvent } from 'react-use-event-hook';
+import { lazyComponent } from '../../../lazy-component';
+import aStyle from '../attachments.module.scss';
+import style from './visual.module.scss';
+import { useItemClickHandler, useLightboxItems } from './hooks';
+import {
+ fitIntoBox,
+ maxEditingPreviewHeight,
+ maxEditingPreviewWidth,
+ minEditingPreviewHeight,
+ minEditingPreviewWidth,
+} from './geometry';
+import { VisualAttachment } from './attachment';
+import { gap } from './gallery';
+
+const Sortable = lazyComponent(() => import('../../../react-sortable'), {
+ fallback:
Loading component...
,
+ errorMessage: "Couldn't load Sortable component",
+});
+
+export function VisualContainerEditable({
+ attachments,
+ removeAttachment,
+ reorderImageAttachments,
+ postId,
+}) {
+ const withSortable = attachments.length > 1;
+ const lightboxItems = useLightboxItems(attachments, postId);
+ const handleClick = useItemClickHandler(lightboxItems);
+
+ const setSortedList = useEvent((list) => reorderImageAttachments(list.map((a) => a.id)));
+
+ const previews = [];
+
+ // Use the single container and the fixed legacy sizes for the reorder ability
+ for (const [i, a] of attachments.entries()) {
+ const { width, height } = fitIntoBox(a, maxEditingPreviewWidth, maxEditingPreviewHeight, true);
+ previews.push(
+
,
+ );
+ }
+
+ return (
+
+ {withSortable ? (
+
+ {previews}
+
+ ) : (
+
{previews}
+ )}
+
+ );
+}
diff --git a/src/components/post/attachments/visual/container-static.jsx b/src/components/post/attachments/visual/container-static.jsx
new file mode 100644
index 000000000..7be52f1ef
--- /dev/null
+++ b/src/components/post/attachments/visual/container-static.jsx
@@ -0,0 +1,124 @@
+import cn from 'classnames';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { faChevronCircleLeft, faChevronCircleRight } from '@fortawesome/free-solid-svg-icons';
+import { useEvent } from 'react-use-event-hook';
+import { Icon } from '../../../fontawesome-icons';
+import aStyle from '../attachments.module.scss';
+import { safeScrollBy } from '../../../../services/unscroll';
+import style from './visual.module.scss';
+import { VisualAttachment } from './attachment';
+import { useItemClickHandler, useLightboxItems, useWidthOf } from './hooks';
+import { gap, getGallerySizes, getSingleImageSize } from './gallery';
+
+export function VisualContainerStatic({
+ attachments,
+ isNSFW,
+ removeAttachment,
+ reorderImageAttachments,
+ postId,
+ isExpanded,
+}) {
+ const containerRef = useRef(null);
+ const containerWidth = useWidthOf(containerRef);
+
+ const lightboxItems = useLightboxItems(attachments, postId);
+ const handleClick = useItemClickHandler(lightboxItems);
+
+ const sizes = attachments.map((a) => ({
+ width: a.previewWidth ?? a.width,
+ height: a.previewHeight ?? a.height,
+ }));
+
+ const singleImage = attachments.length === 1;
+
+ const sizeRows = singleImage
+ ? [{ items: [getSingleImageSize(attachments[0], containerWidth)], stretched: false }]
+ : getGallerySizes(sizes, containerWidth);
+
+ const needFolding = sizeRows.length > 1 && !isExpanded;
+ const [isFolded, setIsFolded] = useState(true);
+
+ const scrollBeforeFold = useRef(null);
+ const toggleFold = useEvent(() => {
+ // Save the position of the container bottom before folding
+ scrollBeforeFold.current = containerRef.current.getBoundingClientRect().bottom;
+ setIsFolded(!isFolded);
+ });
+
+ useLayoutEffect(() => {
+ if (!needFolding || !isFolded || scrollBeforeFold.current === null) {
+ return;
+ }
+ const { top, bottom } = containerRef.current.getBoundingClientRect();
+ if (top < 50) {
+ // If we just folded, and the container is at (or above) the top of the
+ // screen, scroll page to keep its bottom edge at the same place
+ safeScrollBy(0, bottom - scrollBeforeFold.current);
+ }
+ }, [isFolded, needFolding]);
+
+ useEffect(() => {
+ if (!needFolding) {
+ setIsFolded(true);
+ }
+ }, [needFolding]);
+
+ if (containerWidth === 0) {
+ // Looks like a first render, don't render content
+ return
;
+ }
+
+ const previews = [];
+
+ // Use multiple rows and the dynamic sizes
+ let n = 0;
+ for (let k = 0; k < sizeRows.length; k++) {
+ const row = sizeRows[k];
+ const atts = attachments.slice(n, n + row.items.length);
+ const key = atts.map((a) => a.id).join('-');
+
+ const showIcon =
+ needFolding && ((!isFolded && k === sizeRows.length - 1) || (isFolded && k === 0));
+
+ previews.push(
+
+ {atts.map((a, i) => (
+
+ ))}
+ {showIcon && (
+
+
+
+ )}
+
,
+ );
+ n += atts.length;
+ if (showIcon && isFolded) {
+ // Show only the first row
+ break;
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/post/attachments/visual/container.jsx b/src/components/post/attachments/visual/container.jsx
new file mode 100644
index 000000000..808ecd01c
--- /dev/null
+++ b/src/components/post/attachments/visual/container.jsx
@@ -0,0 +1,9 @@
+import { VisualContainerEditable } from './container-editable';
+import { VisualContainerStatic } from './container-static';
+
+export function VisualContainer(props) {
+ if (props.removeAttachment || props.reorderImageAttachments) {
+ return
;
+ }
+ return
;
+}
diff --git a/src/components/post/attachments/visual/gallery.js b/src/components/post/attachments/visual/gallery.js
new file mode 100644
index 000000000..50e3591ee
--- /dev/null
+++ b/src/components/post/attachments/visual/gallery.js
@@ -0,0 +1,213 @@
+/**
+ * @typedef {{width: number, height: number}[]} GalleryRow
+ */
+
+import { fitIntoBox } from './geometry';
+
+export const gap = 8;
+const previewArea = 250 ** 2; // px^2
+export const singleImagePreviewArea = 400 ** 2; // px^2, 16:9 with 300px height
+export const maxHeight = 330;
+const minSize = 40; // Minimum size of image placeholder side
+const maxStretch = 1.3; // Maximum average stretch of last row
+const stretchGap = 20;
+
+export function getSingleImageSize(att, containerWidth) {
+ const { width, height } = fitIntoBox(att, containerWidth, maxHeight);
+
+ const area = width * height;
+ if (area < singleImagePreviewArea) {
+ return {
+ width: Math.max(width, minSize),
+ height: Math.max(height, minSize),
+ };
+ }
+ const ratio = Math.sqrt(singleImagePreviewArea / area);
+
+ return {
+ width: Math.max(Math.round(width * ratio), minSize),
+ height: Math.max(Math.round(height * ratio), minSize),
+ };
+}
+
+/**
+ * @param {{width: number, height: number}[]} imageSizes
+ * @param {number} containerWidth
+ * @param {number} desiredArea
+ * @returns {GalleryRow}
+ */
+export function getGallerySizes(imageSizes, containerWidth) {
+ let start = 0;
+ const lines = [];
+ while (start < imageSizes.length) {
+ const line = getGalleryLine(imageSizes.slice(start), containerWidth);
+ lines.push(line);
+ if (line.items.length === 0) {
+ // Prevent infinite loop
+ throw new Error('Empty gallery line');
+ }
+ start += line.items.length;
+ }
+ return lines;
+}
+
+/**
+ * @param {{width: number, height: number}[]} imageSizes
+ * @param {number} containerWidth
+ * @param {number} maxHeight
+ * @param {number} gap
+ * @param {number} desiredArea
+ * @returns {GalleryRow}
+ */
+function getGalleryLine(imageSizes, containerWidth) {
+ if (containerWidth < Math.sqrt(previewArea)) {
+ // A very narrow container (or just the first render), leave only the first item
+ const { width, height } = fitIntoBox(imageSizes[0], containerWidth, maxHeight);
+ return {
+ items: [{ width: Math.max(width, minSize), height: Math.max(height, minSize) }],
+ stretched: true,
+ };
+ }
+
+ let minPenalty = Infinity;
+ let bestHeight = maxHeight;
+ let results;
+ for (let n = 1; n <= imageSizes.length; n++) {
+ results = imageSizes.slice(0, n);
+ const availableWidth = containerWidth - (n - 1) * gap;
+
+ const height = findRowHeight(results, availableWidth, maxHeight, minSize);
+
+ let penalty = Infinity;
+ const resultsWidth = getRowWidth(results, height);
+
+ // Special case: the first image is already too wide to fit the container.
+ // We cannot drop it, so treat it as a found result.
+ if (n === 1 && resultsWidth > availableWidth) {
+ const size = fitIntoBox(results[0], availableWidth, maxHeight);
+ size.width = Math.max(size.width, minSize);
+ size.height = Math.max(size.height, minSize);
+ return {
+ items: [size],
+ stretched: true,
+ };
+ }
+
+ if (resultsWidth <= availableWidth) {
+ const avgArea = (resultsWidth * height) / n;
+ penalty =
+ // Penalty for average area mismatch
+ Math.abs(Math.log(avgArea / previewArea)) +
+ // (Big) penalty for width mismatch
+ 4 * Math.abs((resultsWidth - availableWidth) / Math.sqrt(previewArea));
+ }
+
+ if (penalty < minPenalty) {
+ minPenalty = penalty;
+ bestHeight = height;
+ } else {
+ results.pop();
+ break;
+ }
+ }
+
+ let items = getRowSizes(results, bestHeight);
+ const availableWidth = containerWidth - (items.length - 1) * gap;
+ const itemsWidth = items.reduce((sum, it) => sum + it.width, 0);
+ const wDiff = availableWidth - itemsWidth;
+ let stretched = wDiff < stretchGap;
+
+ if (stretched) {
+ for (const it of items) {
+ it.width += (wDiff * it.width) / itemsWidth;
+ }
+ }
+
+ // Is it a last line?
+ if (results.length === imageSizes.length) {
+ const stretch = (itemsWidth * bestHeight) / items.length / previewArea;
+ if (
+ // Too expanded
+ stretch > maxStretch &&
+ // and not wider than available width
+ itemsWidth <= availableWidth
+ ) {
+ const height = bestHeight / Math.sqrt(stretch);
+ items = getRowSizes(results, height);
+ stretched = false;
+ }
+ }
+
+ return {
+ items,
+ stretched,
+ };
+}
+
+/**
+ * Calculate sizes of images in a row with a given maximum height. Images, that
+ * are bigger, are scaled to fit the height, smaller are kept as is. Neither
+ * width nor height of any image can be smaller than minSize.
+ *
+ * @param {{width: number, height: number}[]} imageSizes
+ * @param {number} height
+ * @returns {{width: number, height: number}[]}
+ */
+function getRowSizes(imageSizes, height) {
+ return imageSizes.map((it) => {
+ if (it.height <= height) {
+ return { width: Math.max(it.width, minSize), height: Math.max(it.height, minSize) };
+ }
+ return { width: Math.max((it.width * height) / it.height, minSize), height };
+ });
+}
+
+/**
+ * Calculate the total of widths of images in a row.
+ *
+ * @param {{width: number, height: number}[]} imageSizes
+ * @param {number} height
+ * @returns {number}
+ */
+function getRowWidth(imageSizes, height) {
+ return getRowSizes(imageSizes, height).reduce((sum, it) => sum + it.width, 0);
+}
+
+/**
+ * Find the maximum height for a row when the total width is less or equal to
+ * the _availableWidth_. There can be situations, when the available width is
+ * too small, in which case the function returns minHeight.
+ *
+ * @param {{width: number, height: number}[]} imageSizes
+ * @param {number} availableWidth
+ * @param {number} maxHeight
+ * @param {number} minHeight
+ * @returns {number}
+ */
+function findRowHeight(imageSizes, availableWidth, maxHeight, minHeight) {
+ // First try with maxHeight
+ if (getRowWidth(imageSizes, maxHeight) <= availableWidth) {
+ return maxHeight;
+ }
+
+ // Next try with minHeight
+ if (getRowWidth(imageSizes, minHeight) >= availableWidth) {
+ // If even minHeight is too big, return minHeight.
+ return minHeight;
+ }
+
+ // Next try with binary search
+ let low = minHeight;
+ let high = maxHeight;
+
+ while (low + 1 < high) {
+ const mid = Math.floor((low + high) / 2);
+ if (getRowWidth(imageSizes, mid) <= availableWidth) {
+ low = mid;
+ } else {
+ high = mid;
+ }
+ }
+
+ return low;
+}
diff --git a/src/components/post/attachments/visual/geometry.js b/src/components/post/attachments/visual/geometry.js
new file mode 100644
index 000000000..d10200a56
--- /dev/null
+++ b/src/components/post/attachments/visual/geometry.js
@@ -0,0 +1,20 @@
+export const maxEditingPreviewWidth = 400;
+export const maxEditingPreviewHeight = 175;
+export const minEditingPreviewWidth = 60;
+export const minEditingPreviewHeight = 60;
+
+export function fitIntoBox(att, boxWidth, boxHeight, upscale = false) {
+ const [width, height] = [att.previewWidth ?? att.width, att.previewHeight ?? att.height];
+ if (!upscale) {
+ 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/attachments/visual/hooks.js b/src/components/post/attachments/visual/hooks.js
new file mode 100644
index 000000000..292c59351
--- /dev/null
+++ b/src/components/post/attachments/visual/hooks.js
@@ -0,0 +1,113 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useEvent } from 'react-use-event-hook';
+import { attachmentPreviewUrl } from '../../../../services/api';
+import { openLightbox } from '../../../../services/lightbox';
+import { handleLeftClick } from '../../../../utils';
+
+const resizeHandlers = new Map();
+
+const defaultWidth = process.env.NODE_ENV !== 'test' ? 0 : 600;
+
+export function useWidthOf(elRef) {
+ const [width, setWidth] = useState(elRef.current?.offsetWidth || defaultWidth);
+ 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;
+}
+
+export function useLightboxItems(attachments, postId) {
+ return useMemo(
+ () =>
+ attachments.map((a) => ({
+ ...(a.mediaType === 'image'
+ ? { type: 'image', src: attachmentPreviewUrl(a.id, 'image') }
+ : {
+ type: a.meta?.inProgress ? 'in-progress' : 'video',
+ videoSrc: attachmentPreviewUrl(a.id, 'video'),
+ msrc: attachmentPreviewUrl(a.id, 'image'),
+ meta: a.meta ?? {},
+ duration: a.duration ?? 0,
+ }),
+ 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],
+ );
+}
+
+export function useItemClickHandler(lightboxItems) {
+ return useEvent(
+ handleLeftClick((e) => {
+ e.preventDefault();
+ const { currentTarget: el } = e;
+ const index = lightboxItems.findIndex((i) => i.pid === el.dataset.pid);
+ openLightbox(index, lightboxItems, el.target);
+ }),
+ );
+}
+
+// Prevent video from playing infinitely (we has this situation once and don't
+// want it to happen again)
+export function useStopVideo(videoRef, enabled) {
+ useEffect(() => {
+ if (!enabled || !videoRef.current) {
+ return;
+ }
+ const videoEl = videoRef.current;
+
+ // By default, the video playback should be paused after 5 minutes
+ const defaultPlayTime = 300 * 1000;
+ let maxPlayTime = Number.isFinite(videoEl.duration)
+ ? videoEl.duration * 10 * 1000
+ : defaultPlayTime;
+
+ 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 = Math.max(defaultPlayTime, 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();
+ }, [enabled, videoRef]);
+}
diff --git a/src/components/post/attachments/visual/nsfw-canvas.jsx b/src/components/post/attachments/visual/nsfw-canvas.jsx
new file mode 100644
index 000000000..7a831bef5
--- /dev/null
+++ b/src/components/post/attachments/visual/nsfw-canvas.jsx
@@ -0,0 +1,31 @@
+import { useEffect, useRef } from 'react';
+import style from './visual.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;
+ }, [src]);
+
+ return (
+ <>
+
+
NSFW
+ >
+ );
+}
diff --git a/src/components/post/attachments/visual/visual.module.scss b/src/components/post/attachments/visual/visual.module.scss
new file mode 100644
index 000000000..346129cba
--- /dev/null
+++ b/src/components/post/attachments/visual/visual.module.scss
@@ -0,0 +1,220 @@
+@import '../../../../../styles/helvetica/dark-vars.scss';
+
+// Container
+.container--visual {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap, 5px);
+}
+
+.container--sortable {
+ flex-flow: row wrap;
+}
+
+// Preview rows
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--gap, 5px);
+ position: relative;
+}
+
+.row--stretched {
+ justify-content: space-between;
+}
+
+.fold__box {
+ flex: 1;
+ position: relative;
+
+ .row--stretched & {
+ position: absolute;
+ height: 100%;
+ right: 0;
+ }
+}
+
+.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(-6px, -50%);
+
+ :global(.dark-theme) & {
+ color: $link-color-dim;
+ background-color: $bg-color;
+ }
+
+ .row--stretched & {
+ left: auto;
+ right: 0;
+ transform: translate(21px, -50%);
+
+ @media (max-width: 768px) {
+ transform: translate(14px, -50%);
+ }
+ }
+}
+
+// Link
+.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 .link {
+ cursor: move;
+}
+
+// Image/video/processing
+.image,
+.video {
+ grid-area: 1 / 1;
+}
+
+.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;
+ }
+}
+
+.video {
+ opacity: 0;
+ transition: opacity 0.2s;
+
+ .link--inline-video &,
+ .link--playing & {
+ opacity: 1;
+ }
+}
+
+.processing {
+ opacity: 0.8;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.processing__icon {
+ animation: rotate 2s linear infinite;
+}
+
+@keyframes rotate {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+// Overlays
+.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;
+}
+
+.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);
+ }
+}
+
+.overlay--time {
+ bottom: 0.35em;
+ left: 0.35em;
+ pointer-events: none;
+}
+
+.overlay--info :global(.fa-icon) {
+ font-size: 0.85em;
+}
+
+.overlay--remove {
+ top: 0.3em;
+ right: 0.3em;
+ padding: 0.2em;
+ font-size: 1.2em;
+}
+
+.play-icon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 60px;
+ color: white;
+ opacity: 0.66;
+ transition: opacity 0.3s;
+ filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.33));
+}
+
+.link:hover .play-icon {
+ opacity: 1;
+}
+
+// NSFW canvas
+.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/post-attachment-audio.jsx b/src/components/post/post-attachment-audio.jsx
deleted file mode 100644
index 25d1d491c..000000000
--- a/src/components/post/post-attachment-audio.jsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { PureComponent } from 'react';
-import { faFileAudio } from '@fortawesome/free-regular-svg-icons';
-import { faTimes } from '@fortawesome/free-solid-svg-icons';
-
-import { formatFileSize } from '../../utils';
-import { Icon } from '../fontawesome-icons';
-
-class AudioAttachment extends PureComponent {
- handleClickOnRemoveAttachment = () => {
- this.props.removeAttachment(this.props.id);
- };
-
- render() {
- const { props } = this;
- const formattedFileSize = formatFileSize(props.fileSize);
-
- let artistAndTitle = '';
- if (props.title && props.artist) {
- artistAndTitle = `${props.artist} – ${props.title} (${formattedFileSize})`;
- } else if (props.title) {
- artistAndTitle = `${props.title} (${formattedFileSize})`;
- } else {
- artistAndTitle = `${props.fileName} (${formattedFileSize})`;
- }
-
- return (
-
- );
- }
-}
-
-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 7150b41e8..000000000
--- a/src/components/post/post-attachment-general.jsx
+++ /dev/null
@@ -1,38 +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';
-
-class GeneralAttachment extends PureComponent {
- handleClickOnRemoveAttachment = () => {
- this.props.removeAttachment(this.props.id);
- };
-
- render() {
- const { props } = this;
- const formattedFileSize = formatFileSize(props.fileSize);
- const nameAndSize = `${props.fileName} (${formattedFileSize})`;
-
- return (
-
- );
- }
-}
-
-export default GeneralAttachment;
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 227d33db5..000000000
--- a/src/components/post/post-attachment-image-container.jsx
+++ /dev/null
@@ -1,176 +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 ImageAttachment from './post-attachment-image';
-
-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(({ imageSizes: { t, o } }) => (t ? t.w : o ? o.w : 0))
- .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: a.url,
- width: a.imageSizes?.o?.w ?? 1,
- height: a.imageSizes?.o?.h ?? 1,
- pid: this.getPictureId(a),
- autoSize: !a.imageSizes?.o?.w,
- }));
- }
-
- 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 25d44ee9c..000000000
--- a/src/components/post/post-attachment-image.jsx
+++ /dev/null
@@ -1,117 +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';
-
-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 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;
- }
-
- render() {
- const { props } = this;
-
- const formattedFileSize = formatFileSize(props.fileSize);
- const formattedImageSize = props.imageSizes.o
- ? `, ${props.imageSizes.o.w}×${props.imageSizes.o.h}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 imageAttributes = {
- src: (props.imageSizes.t && props.imageSizes.t.url) || props.thumbnailUrl,
- srcSet,
- 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,
- };
-
- 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));
-
- return (
-
- );
- }
-}
-
-export default PostAttachmentImage;
diff --git a/src/components/post/post-attachment-video.jsx b/src/components/post/post-attachment-video.jsx
deleted file mode 100644
index 9c8557a4b..000000000
--- a/src/components/post/post-attachment-video.jsx
+++ /dev/null
@@ -1,93 +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';
-
-export default function VideoAttachment({
- id,
- url,
- fileName,
- fileSize,
- removeAttachment,
- isEditing,
-}) {
- 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 ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- );
-}
diff --git a/src/components/post/post-attachments.jsx b/src/components/post/post-attachments.jsx
deleted file mode 100644
index e69e077bb..000000000
--- a/src/components/post/post-attachments.jsx
+++ /dev/null
@@ -1,131 +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 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) => {
- 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]),
- shallowEqual,
- );
-
- const imageAttachments = [];
- const audioAttachments = [];
- const videoAttachments = [];
- const generalAttachments = [];
-
- attachments.forEach((attachment) => {
- if (attachment.mediaType === 'image') {
- imageAttachments.push(attachment);
- } else if (attachment.mediaType === 'audio') {
- audioAttachments.push(attachment);
- } else if (attachment.mediaType === 'general' && looksLikeAVideoFile(attachment)) {
- videoAttachments.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 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}
- {generalAttachmentsContainer}
-
-
- ) : (
- false
- );
-}
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..8bdaaf971 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,16 +457,13 @@ class Post extends Component {
<>
{this.props.attachments.length > 0 && (