Skip to content

Commit a3ff0e2

Browse files
committed
Merge branch 'media-gallery' into beta
2 parents 733f955 + d65248a commit a3ff0e2

File tree

7 files changed

+131
-12
lines changed

7 files changed

+131
-12
lines changed

src/components/post/attachments/visual/container-editable.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ export function VisualContainerEditable({
2424
removeAttachment,
2525
reorderImageAttachments,
2626
postId,
27+
lightboxOptions,
2728
}) {
2829
const withSortable = attachments.length > 1;
2930
const lightboxItems = useLightboxItems(attachments, postId);
30-
const handleClick = useItemClickHandler(lightboxItems);
31+
const handleClick = useItemClickHandler(lightboxItems, lightboxOptions);
3132

3233
const setSortedList = useEvent((list) => reorderImageAttachments(list.map((a) => a.id)));
3334

src/components/post/attachments/visual/container-static.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@ export function VisualContainerStatic({
1717
reorderImageAttachments,
1818
postId,
1919
isExpanded,
20+
lightboxOptions,
2021
}) {
2122
const containerRef = useRef(null);
2223
const containerWidth = useWidthOf(containerRef);
2324

2425
const lightboxItems = useLightboxItems(attachments, postId);
25-
const handleClick = useItemClickHandler(lightboxItems);
26+
const handleClick = useItemClickHandler(lightboxItems, lightboxOptions);
2627

2728
const sizes = attachments.map((a) => ({
2829
width: a.previewWidth ?? a.width,

src/components/post/attachments/visual/hooks.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ export function useLightboxItems(attachments, postId) {
6969
);
7070
}
7171

72-
export function useItemClickHandler(lightboxItems) {
72+
export function useItemClickHandler(lightboxItems, lightboxOptions) {
7373
return useEvent(
7474
handleLeftClick((e) => {
7575
e.preventDefault();
7676
const { currentTarget: el } = e;
7777
const index = lightboxItems.findIndex((i) => i.pid === el.dataset.pid);
78-
openLightbox(index, lightboxItems, el.target);
78+
openLightbox(index, lightboxItems, lightboxOptions);
7979
}),
8080
);
8181
}

src/components/user-media.jsx

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import FeedOptionsSwitch from './feed-options-switch';
66
import UserProfile from './user-profile';
77
import { useNouter } from '../services/nouter';
88
import PaginatedView from './paginated-view';
9-
import { useMemo, useState } from 'react';
9+
import { useEffect, useMemo, useState } from 'react';
1010
import { VisualContainer } from './post/attachments/visual/container';
1111
import { isPostNSFW } from './select-utils';
1212
import { htmlSafe } from '../utils';
1313
import { Helmet } from 'react-helmet';
14+
import { NEEDMORE_EVENT, MOREITEMS_EVENT } from '../services/lightbox-events';
15+
import { useLightboxItems } from './post/attachments/visual/hooks';
1416

1517
const tokenizeHashtags = hashtags();
18+
const lightboxOptions = { loop: false, pagination: true };
19+
const PAGE_SIZE = 30;
1620

1721
// Persists showNSFW state across remounts within the same userMedia route.
1822
// Resets when switching to a different user.
@@ -35,6 +39,7 @@ export default function UserMedia() {
3539
setShowNSFWState(value);
3640
};
3741
const { attachments, hasNSFW } = useMediaAttachments(foundUser, showNSFW);
42+
useLightboxPagination(attachments);
3843

3944
const nameForTitle = useMemo(
4045
() =>
@@ -76,7 +81,12 @@ export default function UserMedia() {
7681
{canViewAccountContent ? (
7782
attachments.length > 0 ? (
7883
<PaginatedView>
79-
<VisualContainer attachments={attachments} isNSFW={false} isExpanded />
84+
<VisualContainer
85+
attachments={attachments}
86+
isNSFW={false}
87+
isExpanded
88+
lightboxOptions={lightboxOptions}
89+
/>
8090
</PaginatedView>
8191
) : (
8292
<div className="box-body">
@@ -94,6 +104,58 @@ export default function UserMedia() {
94104
);
95105
}
96106

107+
// Enables infinite scroll in the lightbox on the UserMedia page.
108+
//
109+
// The lightbox (lightbox-actual.js) and this page communicate via two custom
110+
// DOM events, without direct dependency on each other:
111+
//
112+
// 1. When the user approaches the last slide in the lightbox, it dispatches
113+
// NEEDMORE_EVENT. This hook listens for it and navigates to the next page
114+
// using `navigate({ replace: true })`. The `replace` flag swaps the lightbox
115+
// history marker with the new page URL (see lightbox-actual.js for details).
116+
//
117+
// 2. Once React processes the new page data and `useMediaAttachments` produces
118+
// a new list of attachments, this hook dispatches MOREITEMS_EVENT with the
119+
// lightbox-formatted items and the `isLastPage` flag. The lightbox receives
120+
// the event, deduplicates items by `pid`, pushes new ones into its
121+
// `dataSource` array, and restores the history marker via `pushState`.
122+
//
123+
// This way the lightbox never touches React/Redux, and this page never imports
124+
// PhotoSwipe — they only share event name constants from lightbox-events.js.
125+
function useLightboxPagination(attachments) {
126+
const { navigate, location } = useNouter();
127+
const isLastPage = useSelector((state) => state.feedViewState.isLastPage);
128+
const lightboxItems = useLightboxItems(attachments);
129+
130+
// Listen for "need more" requests from the lightbox
131+
useEffect(() => {
132+
let loading = false;
133+
const handler = () => {
134+
if (loading || isLastPage) {
135+
return;
136+
}
137+
loading = true;
138+
const offset = (+location.query.offset || 0) + PAGE_SIZE;
139+
navigate(
140+
{ pathname: location.pathname, query: { ...location.query, offset } },
141+
{ replace: true },
142+
);
143+
};
144+
document.addEventListener(NEEDMORE_EVENT, handler);
145+
return () => document.removeEventListener(NEEDMORE_EVENT, handler);
146+
}, [navigate, location, isLastPage]);
147+
148+
// Dispatch new items to the lightbox when attachments change
149+
useEffect(() => {
150+
if (lightboxItems.length === 0) {
151+
return;
152+
}
153+
document.dispatchEvent(
154+
new CustomEvent(MOREITEMS_EVENT, { detail: { items: lightboxItems, isLastPage } }),
155+
);
156+
}, [lightboxItems, isLastPage]);
157+
}
158+
97159
// Same logic as in UserProfileHead: don't show media for private/banned accounts
98160
function useCanViewAccountContent(user) {
99161
const currentUser = useSelector((state) => state.user);

src/services/lightbox-actual.js

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import { getFullscreenAPI } from '../utils/fullscreen';
99
import { isGifLike } from '../components/post/attachments/visual/utils';
1010
import { intentToScroll } from './unscroll';
1111
import { handlePip } from './pip-video';
12+
import { NEEDMORE_EVENT, MOREITEMS_EVENT } from './lightbox-events';
1213

1314
const prevHotKeys = ['a', 'ф', 'h', 'р', '4'];
1415
const nextHotKeys = ['d', 'в', 'k', 'л', '6'];
1516
const fullScreenHotKeys = ['f', 'а'];
1617

17-
export function openLightbox(index, dataSource) {
18-
initLightbox().loadAndOpen(index, dataSource);
18+
export function openLightbox(index, dataSource, options) {
19+
initLightbox(options).loadAndOpen(index, dataSource);
1920
}
2021

2122
const fullScreenAPI = getFullscreenAPI();
@@ -38,7 +39,9 @@ const downloadIconHtml = {
3839
outlineID: 'pswp__icn-download',
3940
};
4041

41-
function initLightbox() {
42+
const paginationThreshold = 3;
43+
44+
function initLightbox({ loop = true, pagination = false } = {}) {
4245
const lightbox = new PhotoSwipeLightbox({
4346
clickToCloseNonZoomable: false,
4447
tapAction(_, event) {
@@ -54,6 +57,7 @@ function initLightbox() {
5457
maxZoomLevel: 2,
5558
pswpModule,
5659
returnFocus: false,
60+
loop,
5761
});
5862

5963
new PhotoSwipeVideoPlugin(lightbox, {});
@@ -167,14 +171,19 @@ function initLightbox() {
167171
});
168172

169173
// Handle back button
174+
// Push a "marker" history entry so pressing Back closes the lightbox.
175+
// We preserve the history library's state (which contains the location key)
176+
// so that popping this marker doesn't look like a new navigation to the router.
177+
const pushMarker = () => history.pushState(window.history.state, '');
178+
170179
let closedByNavigation = false;
171180
const close = () => {
172181
lightbox.pswp.close();
173182
closedByNavigation = true;
174183
};
175184
lightbox.on('beforeOpen', () => {
176185
window.addEventListener('popstate', close);
177-
history.pushState(null, '');
186+
pushMarker();
178187
});
179188
lightbox.on('destroy', () => {
180189
window.removeEventListener('popstate', close);
@@ -183,6 +192,47 @@ function initLightbox() {
183192
}
184193
});
185194

195+
// Pagination: request more items when approaching the last slide
196+
if (pagination) {
197+
let isLastPage = false;
198+
let needmoreDispatched = false;
199+
200+
lightbox.on('beforeOpen', () => {
201+
const onMoreItems = (e) => {
202+
const { items, isLastPage: last } = e.detail;
203+
isLastPage = last;
204+
needmoreDispatched = false;
205+
206+
const dataSource = lightbox.pswp.options.dataSource;
207+
const existingPids = new Set(dataSource.map((d) => d.pid));
208+
const newItems = items.filter((item) => !existingPids.has(item.pid));
209+
if (newItems.length > 0) {
210+
dataSource.push(...newItems);
211+
}
212+
213+
// Restore the history marker after navigation (replaceState removed it)
214+
pushMarker();
215+
};
216+
217+
document.addEventListener(MOREITEMS_EVENT, onMoreItems);
218+
lightbox.on('destroy', () => document.removeEventListener(MOREITEMS_EVENT, onMoreItems));
219+
});
220+
221+
lightbox.on('bindEvents', () => {
222+
lightbox.pswp.on('change', () => {
223+
if (isLastPage || needmoreDispatched) {
224+
return;
225+
}
226+
const total = lightbox.pswp.getNumItems();
227+
const curr = lightbox.pswp.currIndex;
228+
if (curr >= total - paginationThreshold) {
229+
needmoreDispatched = true;
230+
document.dispatchEvent(new CustomEvent(NEEDMORE_EVENT));
231+
}
232+
});
233+
});
234+
}
235+
186236
// Looking for video in active slide
187237
let currentVideo = null;
188238
lightbox.on('contentActivate', ({ content }) => {

src/services/lightbox-events.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Custom DOM events for communication between the lightbox and React components.
2+
// The lightbox dispatches NEEDMORE when approaching the last slide,
3+
// and listens for MOREITEMS with new slide data from the page component.
4+
export const NEEDMORE_EVENT = 'lightbox:needmore';
5+
export const MOREITEMS_EVENT = 'lightbox:moreitems';

src/services/lightbox.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/* eslint-disable no-console */
22

33
let firstOpen = true;
4-
export function openLightbox(index, dataSource) {
4+
export function openLightbox(index, dataSource, options) {
55
if (firstOpen && dataSource[index].src) {
66
// Preload image
77
new Image().src = dataSource[index].src;
88
}
99
firstOpen = false;
1010
import('./lightbox-actual')
11-
.then((m) => m.openLightbox(index, dataSource))
11+
.then((m) => m.openLightbox(index, dataSource, options))
1212
.catch((e) => console.error('Could not load lightbox', e));
1313
}
1414

0 commit comments

Comments
 (0)