Skip to content

Commit bdaef33

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

File tree

8 files changed

+156
-3
lines changed

8 files changed

+156
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ 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).
1112

1213
## [1.141.3] - 2025-10-28
1314
### 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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useEffect, useState } from 'react';
2+
import { freefeedAttachmentId, IMAGE, useMediaLink } 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 !== 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+
return (
34+
<a href={url} target="_blank" rel="noreferrer" onClick={handleClick}>
35+
<img src={url} alt="" width={60} height={60} className={styles.preview} loading="lazy" />
36+
</a>
37+
);
38+
}
39+
40+
function FreeFeedMediaPreview({ id }) {
41+
const [src, setSrc] = useState(null);
42+
43+
useEffect(() => {
44+
getAttachmentInfo(id)
45+
.then((info) => {
46+
if (info?.mediaType === 'image' || info?.mediaType === 'video') {
47+
setSrc(attachmentPreviewUrl(id, 'image', 120, 120));
48+
}
49+
return null;
50+
})
51+
.catch((err) => {
52+
// Just ignore
53+
// eslint-disable-next-line no-console
54+
console.error('Failed to get attachment info', id, err);
55+
});
56+
}, [id]);
57+
58+
return src ? (
59+
<img src={src} alt="" width={60} height={60} className={styles.preview} loading="lazy" />
60+
) : null;
61+
}
62+
63+
function YouTubeMediaPreview({ url }) {
64+
const aspectRatio = getDefaultAspectRatio(url);
65+
return (
66+
<img
67+
src={`https://img.youtube.com/vi/${getVideoId(url)}/default.jpg`}
68+
alt=""
69+
width={60 / aspectRatio}
70+
height={60}
71+
className={styles.preview}
72+
loading="lazy"
73+
/>
74+
);
75+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.preview {
2+
height: 4em;
3+
object-fit: cover;
4+
box-shadow: 0 0 0 1px rgb(0, 0, 0, 0.2);
5+
6+
&:hover {
7+
box-shadow: 0 0 0 1px rgb(0, 0, 0, 1);
8+
}
9+
10+
:global(.dark-theme) & {
11+
box-shadow: 0 0 0 1px rgb(255, 255, 255, 0.2);
12+
13+
&:hover {
14+
box-shadow: 0 0 0 1px rgb(255, 255, 255, 0.8);
15+
}
16+
}
17+
}
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 } 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 === 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)