Skip to content

Commit 4306604

Browse files
committed
Show media links previews under the comments
1 parent 76acab2 commit 4306604

File tree

8 files changed

+175
-3
lines changed

8 files changed

+175
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [1.142.0] - Not released
99
### Added
1010
- Show found users and groups on the search results page.
11+
- Show previews of media links in comments (disabled by default). Supported link
12+
types: FreeFeed images and videos, external images, YouTube videos and shorts.
1113

1214
## [1.141.3] - 2025-10-28
1315
### Fixed

src/components/link-preview/video.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export function getVideoType(url) {
174174
return null;
175175
}
176176

177-
function getVideoId(url) {
177+
export function getVideoId(url) {
178178
let m;
179179
if ((m = YOUTUBE_VIDEO_RE.exec(url))) {
180180
return m[1];
@@ -200,7 +200,7 @@ function getVideoId(url) {
200200
return null;
201201
}
202202

203-
function getDefaultAspectRatio(url) {
203+
export function getDefaultAspectRatio(url) {
204204
if (YOUTUBE_VIDEO_RE.test(url)) {
205205
return isYoutubeShort(url) ? 16 / 9 : 9 / 16;
206206
}

src/components/media-links/helpers.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function createErrorItem(error) {
105105

106106
const freefeedPathRegex = /^\/attachments\/(?:\w+\/)?([\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12})/;
107107

108-
function freefeedAttachmentId(url) {
108+
export function freefeedAttachmentId(url) {
109109
try {
110110
const urlObj = new URL(url);
111111
if (!CONFIG.attachmentDomains.includes(urlObj.hostname)) {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useEffect, useState } from 'react';
2+
import { freefeedAttachmentId, IMAGE, useMediaLink, VIDEO } from './helpers';
3+
import { getAttachmentInfo } from '../../services/batch-attachments-info';
4+
import { attachmentPreviewUrl } from '../../services/api';
5+
import { getDefaultAspectRatio, getVideoId, T_YOUTUBE_VIDEO } from '../link-preview/video';
6+
import styles from './media-link-preview.module.scss';
7+
8+
export function MediaLinkPreview({ href: url }) {
9+
const [mediaType, handleClick] = useMediaLink(url);
10+
11+
if (mediaType !== IMAGE && mediaType !== VIDEO && mediaType !== T_YOUTUBE_VIDEO) {
12+
return null;
13+
}
14+
15+
// Freefeed attachment?
16+
const attId = freefeedAttachmentId(url);
17+
if (attId) {
18+
return (
19+
<a href={url} target="_blank" rel="noreferrer" onClick={handleClick}>
20+
<FreeFeedMediaPreview id={attId} />
21+
</a>
22+
);
23+
}
24+
25+
if (mediaType === T_YOUTUBE_VIDEO) {
26+
return (
27+
<a href={url} target="_blank" rel="noreferrer" onClick={handleClick}>
28+
<YouTubeMediaPreview url={url} />
29+
</a>
30+
);
31+
}
32+
33+
if (mediaType === VIDEO) {
34+
return null;
35+
}
36+
37+
return (
38+
<a href={url} target="_blank" rel="noreferrer" onClick={handleClick}>
39+
<img src={url} alt="" width={60} height={60} className={styles.preview} loading="lazy" />
40+
</a>
41+
);
42+
}
43+
44+
function FreeFeedMediaPreview({ id }) {
45+
const [attrs, setAttrs] = useState(null);
46+
47+
useEffect(() => {
48+
getAttachmentInfo(id)
49+
.then((info) => {
50+
if (info?.mediaType === 'image' || info?.mediaType === 'video') {
51+
const height = 120;
52+
const width = Math.max(90, (info.width / info.height) * height);
53+
setAttrs({
54+
src: attachmentPreviewUrl(id, 'image', width, height),
55+
width,
56+
height,
57+
});
58+
}
59+
return null;
60+
})
61+
.catch((err) => {
62+
// Just ignore
63+
// eslint-disable-next-line no-console
64+
console.error('Failed to get attachment info', id, err);
65+
});
66+
}, [id]);
67+
68+
return attrs ? (
69+
<img
70+
src={attrs.src}
71+
alt=""
72+
width={attrs.width}
73+
height={attrs.height}
74+
className={styles.preview}
75+
loading="lazy"
76+
/>
77+
) : null;
78+
}
79+
80+
function YouTubeMediaPreview({ url }) {
81+
const aspectRatio = getDefaultAspectRatio(url);
82+
return (
83+
<img
84+
src={`https://img.youtube.com/vi/${getVideoId(url)}/default.jpg`}
85+
alt=""
86+
width={60 / aspectRatio}
87+
height={60}
88+
className={styles.preview}
89+
loading="lazy"
90+
/>
91+
);
92+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.preview {
2+
height: 4em;
3+
width: auto;
4+
object-fit: cover;
5+
box-shadow: 0 0 0 1px rgb(0, 0, 0, 0.2);
6+
7+
&:hover {
8+
box-shadow: 0 0 0 1px rgb(0, 0, 0, 1);
9+
}
10+
11+
:global(.dark-theme) & {
12+
box-shadow: 0 0 0 1px rgb(255, 255, 255, 0.2);
13+
14+
&:hover {
15+
box-shadow: 0 0 0 1px rgb(255, 255, 255, 0.8);
16+
}
17+
}
18+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { isLocalLink, parseText } from '../../utils/parse-text';
2+
import ErrorBoundary from '../error-boundary';
3+
import { T_YOUTUBE_VIDEO } from '../link-preview/video';
4+
import { getMediaType, IMAGE, VIDEO } from '../media-links/helpers';
5+
import { MediaLinkPreview } from '../media-links/media-link-preview';
6+
import { MediaLinksProvider } from '../media-links/provider';
7+
import styles from './comment-media-previews.module.scss';
8+
9+
export function CommentMediaPreviews({ text }) {
10+
const result = [];
11+
12+
const tokens = text ? parseText(text) : [];
13+
14+
let inSpoiler = false;
15+
for (const [index, token] of tokens.entries()) {
16+
if (token.type === 'SPOILER_START') {
17+
inSpoiler = true;
18+
} else if (token.type === 'SPOILER_END') {
19+
inSpoiler = false;
20+
} else if (token.type === 'LINK' && !inSpoiler) {
21+
if (
22+
!isLocalLink(token.text) &&
23+
/^https?:\/\//i.test(token.text) &&
24+
text.charAt(token.offset - 1) !== '!'
25+
) {
26+
const type = getMediaType(token.text);
27+
if (type === IMAGE || type === VIDEO || type === T_YOUTUBE_VIDEO) {
28+
result.push(<MediaLinkPreview key={index} href={token.text} />);
29+
}
30+
}
31+
}
32+
}
33+
34+
return (
35+
<div className={styles.previews}>
36+
<ErrorBoundary>
37+
<MediaLinksProvider>{result}</MediaLinksProvider>
38+
</ErrorBoundary>
39+
</div>
40+
);
41+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
@use '../../../styles/shared/mixins';
2+
3+
.previews {
4+
display: flex;
5+
flex-wrap: wrap;
6+
gap: 0.33em;
7+
margin-left: mixins.rem(19px);
8+
margin-top: 0.25em;
9+
}

src/components/post/post-comment.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { PostCommentMore } from './post-comment-more';
2929
import { PostCommentPreview } from './post-comment-preview';
3030
import { CommentProvider } from './post-comment-provider';
3131
import { DraftIndicator } from './draft-indicator';
32+
import { CommentMediaPreviews } from './comment-media-previews';
3233

3334
class PostComment extends Component {
3435
commentContainer;
@@ -380,6 +381,10 @@ class PostComment extends Component {
380381
);
381382
}
382383

384+
renderMediaPreviews() {
385+
return this.props.showMediaPreviews ? <CommentMediaPreviews text={this.props.body} /> : null;
386+
}
387+
383388
render() {
384389
const className = classnames({
385390
comment: true,
@@ -403,6 +408,7 @@ class PostComment extends Component {
403408
>
404409
{this.renderCommentIcon()}
405410
{this.renderBody()}
411+
{this.renderMediaPreviews()}
406412
{this.renderPreview()}
407413
</div>
408414
);
@@ -415,6 +421,9 @@ function selectState(state, ownProps) {
415421
const showTimestamps =
416422
state.user.frontendPreferences?.comments?.showTimestamps ||
417423
CONFIG.frontendPreferences.defaultValues.comments.showTimestamps;
424+
const showMediaPreviews =
425+
state.user.frontendPreferences?.comments?.showMediaPreviews ||
426+
CONFIG.frontendPreferences.defaultValues.comments.showMediaPreviews;
418427
const { highlightComments } = state.user.frontendPreferences.comments;
419428
const isReplyToBanned = (() => {
420429
if (
@@ -435,6 +444,7 @@ function selectState(state, ownProps) {
435444
return {
436445
...editState,
437446
showTimestamps,
447+
showMediaPreviews,
438448
highlightComments,
439449
isEditing: ownProps.isEditing || editState.isEditing,
440450
submitMode: state.submitMode,

0 commit comments

Comments
 (0)