diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd75d8b4..11f5e2307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.146.0] - Not released +### Added +- "All media" page for users and groups with single gallery of all visual + attachments from all page's posts. +- Lightbox captions on "All media" showing author avatar, username, and a post + text excerpt with a link to the original post. ## [1.145.0] - 2026-02-21 ### Added diff --git a/src/app.jsx b/src/app.jsx index a831ea50c..b62b0473c 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -41,6 +41,7 @@ const Subscriptions = lazyLoad(() => import('./components/subscriptions')); const Summary = lazyLoad(() => import('./components/summary')); const Groups = lazyLoad(() => import('./components/groups')); const BacklinksFeed = lazyLoad(() => import('./components/backlinks-feed')); +const UserMedia = lazyLoad(() => import('./components/user-media')); Sentry.init({ dsn: CONFIG.sentry.publicDSN, @@ -419,6 +420,12 @@ function App() { component={User} {...generateRouteHooks(boundRouteActions('userLikes'))} /> + diff --git a/src/components/layout.jsx b/src/components/layout.jsx index e331c6da6..91826b150 100644 --- a/src/components/layout.jsx +++ b/src/components/layout.jsx @@ -20,6 +20,7 @@ import { LayoutHeader } from './layout-header'; import { UIScaleSetter } from './ui-scale-setter'; import { UndoContainer } from './undo/undo-container'; import { RtlTextSetter } from './rtl-text-setter'; +import { LightboxLinkInterceptor } from './lightbox-link-interceptor'; const loadingPageMessage = ( @@ -153,6 +154,7 @@ class Layout extends Component { + diff --git a/src/components/lightbox-link-interceptor.jsx b/src/components/lightbox-link-interceptor.jsx new file mode 100644 index 000000000..ce7cf4b8f --- /dev/null +++ b/src/components/lightbox-link-interceptor.jsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useNouter } from '../services/nouter'; + +/** + * Intercepts clicks on .pswp-caption__link elements (inside the PhotoSwipe + * lightbox) and navigates via the SPA router instead of a full page reload. + */ +export function LightboxLinkInterceptor() { + const { navigate } = useNouter(); + + useEffect(() => { + const handler = (e) => { + const link = e.target.closest?.('.pswp-caption__link'); + if (!link) { + return; + } + // Let the browser handle modified clicks and non-left-button clicks + if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || e.button !== 0) { + return; + } + + const href = link.getAttribute('href'); + if (!href || !href.startsWith('/')) { + return; + } + e.preventDefault(); + + // The lightbox pushes a history entry on open and calls history.back() + // on destroy (unless closed by navigation). Triggering history.back() + // here makes the lightbox's popstate handler close it with the + // closedByNavigation flag, so it won't call history.back() again. + // We then navigate after the small timeout to allow the lightbox cleanup. + history.back(); + setTimeout(() => navigate(href), 500); + }; + + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, [navigate]); + + return null; +} diff --git a/src/components/post/attachments/visual/container-editable.jsx b/src/components/post/attachments/visual/container-editable.jsx index 04fc1fcd9..3bb69a310 100644 --- a/src/components/post/attachments/visual/container-editable.jsx +++ b/src/components/post/attachments/visual/container-editable.jsx @@ -24,10 +24,11 @@ export function VisualContainerEditable({ removeAttachment, reorderImageAttachments, postId, + lightboxOptions, }) { const withSortable = attachments.length > 1; const lightboxItems = useLightboxItems(attachments, postId); - const handleClick = useItemClickHandler(lightboxItems); + const handleClick = useItemClickHandler(lightboxItems, lightboxOptions); const setSortedList = useEvent((list) => reorderImageAttachments(list.map((a) => a.id))); diff --git a/src/components/post/attachments/visual/container-static.jsx b/src/components/post/attachments/visual/container-static.jsx index 7be52f1ef..7a9cd53f0 100644 --- a/src/components/post/attachments/visual/container-static.jsx +++ b/src/components/post/attachments/visual/container-static.jsx @@ -17,12 +17,13 @@ export function VisualContainerStatic({ reorderImageAttachments, postId, isExpanded, + lightboxOptions, }) { const containerRef = useRef(null); const containerWidth = useWidthOf(containerRef); const lightboxItems = useLightboxItems(attachments, postId); - const handleClick = useItemClickHandler(lightboxItems); + const handleClick = useItemClickHandler(lightboxItems, lightboxOptions); const sizes = attachments.map((a) => ({ width: a.previewWidth ?? a.width, @@ -89,7 +90,7 @@ export function VisualContainerStatic({ removeAttachment={removeAttachment} reorderImageAttachments={reorderImageAttachments} postId={postId} - isNSFW={isNSFW} + isNSFW={a.isNSFW ?? isNSFW} width={row.items[i].width} height={row.items[i].height} pictureId={lightboxItems[n + i].pid} diff --git a/src/components/post/attachments/visual/hooks.js b/src/components/post/attachments/visual/hooks.js index 57c2f32f9..e3f6252b7 100644 --- a/src/components/post/attachments/visual/hooks.js +++ b/src/components/post/attachments/visual/hooks.js @@ -63,18 +63,19 @@ export function useLightboxItems(attachments, postId) { width: a.previewWidth ?? a.width, height: a.previewHeight ?? a.height, pid: `${postId?.slice(0, 8) ?? 'new-post'}-${a.id.slice(0, 8)}`, + ...(a.caption ? { caption: a.caption } : {}), })), [attachments, postId], ); } -export function useItemClickHandler(lightboxItems) { +export function useItemClickHandler(lightboxItems, lightboxOptions) { 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); + openLightbox(index, lightboxItems, lightboxOptions); }), ); } diff --git a/src/components/select-utils.js b/src/components/select-utils.js index d26c5b923..9fa7096e3 100644 --- a/src/components/select-utils.js +++ b/src/components/select-utils.js @@ -83,15 +83,7 @@ export const joinPostData = (state) => (postId) => { } // Get the list of post's recipients - const recipients = post.postedTo - .map((subscriptionId) => { - const userId = (state.subscriptions[subscriptionId] || {}).user; - const subscriptionType = (state.subscriptions[subscriptionId] || {}).name; - const isDirectToSelf = userId === post.createdBy && subscriptionType === 'Directs'; - return !isDirectToSelf ? userId : false; - }) - .map((userId) => state.subscribers[userId] || state.users[userId]) - .filter((user) => user); + const recipients = getPostRecipients(post, state); // All recipient names and the post's author name. // Sorted alphabetically but author name is always comes first. @@ -119,11 +111,7 @@ export const joinPostData = (state) => (postId) => { // Can the current user fully delete this post? const isDeletable = isEditable || canBeRemovedFrom.length === recipients.length; - const isNSFW = - !state.isNSFWVisible && - [post.body, ...recipients.map((r) => r.description)].some((text) => - tokenizeHashtags(text).some((t) => t.text.toLowerCase() === '#nsfw'), - ); + const isNSFW = isPostNSFW(post, state, recipients); const attachments = post.attachments || emptyArray; const postViewState = state.postsViewState[post.id]; @@ -247,6 +235,28 @@ export function userActions(dispatch) { }; } +export function getPostRecipients(post, state) { + return post.postedTo + .map((subscriptionId) => { + const userId = (state.subscriptions[subscriptionId] || {}).user; + const subscriptionType = (state.subscriptions[subscriptionId] || {}).name; + const isDirectToSelf = userId === post.createdBy && subscriptionType === 'Directs'; + return !isDirectToSelf ? userId : false; + }) + .map((userId) => state.subscribers[userId] || state.users[userId]) + .filter((user) => user); +} + +export function isPostNSFW(post, state, recipients) { + if (state.isNSFWVisible || !post) { + return false; + } + const recips = recipients || getPostRecipients(post, state); + return [post.body, ...recips.map((r) => r.description)].some((text) => + tokenizeHashtags(text).some((t) => t.text.toLowerCase() === '#nsfw'), + ); +} + /** * Returns privacy flags of non-direct post posted to the given * destinations. Destinations should be a current users feed or groups. diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx new file mode 100644 index 000000000..1a31cb59c --- /dev/null +++ b/src/components/user-media.jsx @@ -0,0 +1,251 @@ +/* global CONFIG */ +import { useSelector, useStore } from 'react-redux'; +import { hashtags } from 'social-text-tokenizer'; +import Breadcrumbs from './breadcrumbs'; +import FeedOptionsSwitch from './feed-options-switch'; +import UserProfile from './user-profile'; +import { useNouter } from '../services/nouter'; +import PaginatedView from './paginated-view'; +import { useEffect, useMemo, useState } from 'react'; +import { VisualContainer } from './post/attachments/visual/container'; +import { isPostNSFW } from './select-utils'; +import { htmlSafe } from '../utils'; +import { Helmet } from 'react-helmet'; +import { NEEDMORE_EVENT, MOREITEMS_EVENT } from '../services/lightbox-events'; +import { useLightboxItems } from './post/attachments/visual/hooks'; + +const tokenizeHashtags = hashtags(); +const lightboxOptions = { loop: false, pagination: true }; +const PAGE_SIZE = 30; + +// Persists showNSFW state across remounts within the same userMedia route. +// Resets when switching to a different user. +let nsfwToggleState = { username: null, showNSFW: false }; + +export default function UserMedia() { + const { params } = useNouter(); + const username = params.userName.toLowerCase(); + const foundUser = useSelector((state) => + Object.values(state.users).find((u) => u.username === username), + ); + const canViewAccountContent = useCanViewAccountContent(foundUser); + + if (nsfwToggleState.username !== username) { + nsfwToggleState = { username, showNSFW: false }; + } + const [showNSFW, setShowNSFWState] = useState(nsfwToggleState.showNSFW); + const setShowNSFW = (value) => { + nsfwToggleState.showNSFW = value; + setShowNSFWState(value); + }; + const { attachments, hasNSFW } = useMediaAttachments(foundUser, showNSFW); + useLightboxPagination(attachments); + + const nameForTitle = useMemo( + () => + foundUser.username === foundUser.screenName + ? foundUser.username + : `${foundUser.screenName} (${foundUser.username})`, + [foundUser.screenName, foundUser.username], + ); + + return ( +
+ + + {nameForTitle} - All media - {CONFIG.siteTitle} + + + +
+
+ +
+
+
+ + +
+ {canViewAccountContent && hasNSFW && ( +
+ +
+ )} + {canViewAccountContent ? ( + attachments.length > 0 ? ( + + + + ) : ( +
+

There is no media in this account.

+
+ ) + ) : ( + foundUser && ( +
+

Media is not available for this account.

+
+ ) + )} +
+ ); +} + +// Enables infinite scroll in the lightbox on the UserMedia page. +// +// The lightbox (lightbox-actual.js) and this page communicate via two custom +// DOM events, without direct dependency on each other: +// +// 1. When the user approaches the last slide in the lightbox, it dispatches +// NEEDMORE_EVENT. This hook listens for it and navigates to the next page +// using `navigate({ replace: true })`. The `replace` flag swaps the lightbox +// history marker with the new page URL (see lightbox-actual.js for details). +// +// 2. Once React processes the new page data and `useMediaAttachments` produces +// a new list of attachments, this hook dispatches MOREITEMS_EVENT with the +// lightbox-formatted items and the `isLastPage` flag. The lightbox receives +// the event, deduplicates items by `pid`, pushes new ones into its +// `dataSource` array, and restores the history marker via `pushState`. +// +// This way the lightbox never touches React/Redux, and this page never imports +// PhotoSwipe — they only share event name constants from lightbox-events.js. +function useLightboxPagination(attachments) { + const { navigate, location } = useNouter(); + const isLastPage = useSelector((state) => state.feedViewState.isLastPage); + const lightboxItems = useLightboxItems(attachments); + + // Listen for "need more" requests from the lightbox + useEffect(() => { + let loading = false; + const handler = () => { + if (loading || isLastPage) { + return; + } + loading = true; + const offset = (+location.query.offset || 0) + PAGE_SIZE; + navigate( + { pathname: location.pathname, query: { ...location.query, offset } }, + { replace: true }, + ); + }; + document.addEventListener(NEEDMORE_EVENT, handler); + return () => document.removeEventListener(NEEDMORE_EVENT, handler); + }, [navigate, location, isLastPage]); + + // Dispatch new items to the lightbox when attachments change + useEffect(() => { + if (lightboxItems.length === 0) { + return; + } + document.dispatchEvent( + new CustomEvent(MOREITEMS_EVENT, { detail: { items: lightboxItems, isLastPage } }), + ); + }, [lightboxItems, isLastPage]); +} + +// Same logic as in UserProfileHead: don't show media for private/banned accounts +function useCanViewAccountContent(user) { + const currentUser = useSelector((state) => state.user); + return useMemo(() => { + if (!user) { + return false; + } + const isCurrentUser = currentUser?.id === user.id; + const isBanned = currentUser?.banIds?.includes(user.id); + const inSubscriptions = currentUser?.subscriptions.includes(user.id); + return !isBanned && (isCurrentUser || user.isPrivate === '0' || inSubscriptions); + }, [user, currentUser]); +} + +function useMediaAttachments(foundUser, showNSFW) { + // useStore instead of multiple useSelectors for subscriptions/subscribers/users: + // isPostNSFW needs these slices, but we don't want to re-render the media + // gallery every time they change. store.getState() inside useMemo gives us + // the current values without adding them as reactive dependencies. + const store = useStore(); + const allPosts = useSelector((state) => state.posts); + const allAttachments = useSelector((state) => state.attachments); + const feedPostIds = useSelector((state) => state.feedViewState.entries); + const isNSFWVisible = useSelector((state) => state.isNSFWVisible); + + return useMemo(() => { + // Fast path: if the user/group itself has #nsfw in description, all posts are NSFW + const allNSFW = + !isNSFWVisible && + !!foundUser?.description && + tokenizeHashtags(foundUser.description).some((t) => t.text.toLowerCase() === '#nsfw'); + + const state = store.getState(); + + // Build a postId → isNSFW map (one check per post, not per attachment) + const nsfwByPostId = new Map(); + for (const postId of feedPostIds) { + const post = allPosts[postId]; + if (post && !post.deleted) { + nsfwByPostId.set(postId, allNSFW || isPostNSFW(post, state)); + } + } + + let hasNSFW = false; + const attachments = feedPostIds + .flatMap((postId) => { + const post = allPosts[postId]; + const isNSFW = nsfwByPostId.get(postId) ?? false; + if (isNSFW) { + hasNSFW = true; + } + const nsfw = isNSFW && !showNSFW; + return (post?.attachments || []).map((attId) => ({ postId, attId, nsfw })); + }) + .map(({ postId, attId, nsfw }) => { + const att = allAttachments[attId]; + if (!att) { + return null; + } + const post = allPosts[postId]; + const author = post ? state.users[post.createdBy] : null; + const caption = buildCaption(author, post, postId); + return { ...att, isNSFW: nsfw, caption }; + }) + .filter((att) => att && (att.mediaType === 'image' || att.mediaType === 'video')); + + return { attachments, hasNSFW }; + }, [feedPostIds, allPosts, allAttachments, isNSFWVisible, showNSFW, foundUser, store]); +} + +function buildCaption(author, post, postId) { + if (!author || !post) { + return ''; + } + const avatarUrl = author.profilePictureMediumUrl || ''; + const username = htmlSafe(author.username); + const postUrl = `/${encodeURIComponent(author.username)}/${encodeURIComponent(postId)}`; + + const maxLen = 150; + const rawBody = (post.body || '').replace(/\s+/g, ' ').trim(); + const body = + rawBody.length > maxLen ? htmlSafe(rawBody.slice(0, maxLen)) + '\u2026' : htmlSafe(rawBody); + + // The link is intercepted by LightboxLinkInterceptor (in layout.jsx), + // which handles SPA navigation instead of a full page reload. + return ( + `` + + ` ` + + `${username}` + + (body ? `: ${body}` : '') + + `` + ); +} diff --git a/src/components/user-profile-head-stats.jsx b/src/components/user-profile-head-stats.jsx index 416fb5958..63d03af3d 100644 --- a/src/components/user-profile-head-stats.jsx +++ b/src/components/user-profile-head-stats.jsx @@ -85,9 +85,20 @@ export const UserProfileHeadStats = ({ user, canFollowStatLinks }) => { canFollow={canFollowStatLinks} maxDigits={maxDigits} /> + {canFollowStatLinks ? ( +
  • + + All media + +
  • + ) : null} )} -
  • +
  • Since{' '} diff --git a/src/components/user-profile-head.module.scss b/src/components/user-profile-head.module.scss index 601bffdcb..ccef50178 100644 --- a/src/components/user-profile-head.module.scss +++ b/src/components/user-profile-head.module.scss @@ -126,9 +126,18 @@ $actions-padding: 0.75em; white-space: nowrap; } - .allPosts { + .allPosts, + .sinceDate { margin-top: 0.5em; } + + .allMediaLink { + padding-left: calc(0.55em * var(--max-digits, 3) - 1.1em); + + @media (max-width: 600px) { + padding-left: 0; + } + } } @media (max-width: 600px) { @@ -152,6 +161,10 @@ $actions-padding: 0.75em; text-transform: lowercase; } + .sinceDate { + margin-top: 0; + } + .registeredOn, .invitedBy, .moreDetails { diff --git a/src/components/user-profile.jsx b/src/components/user-profile.jsx index da1201be6..82a008033 100644 --- a/src/components/user-profile.jsx +++ b/src/components/user-profile.jsx @@ -1,5 +1,6 @@ +import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { Link } from '../services/nouter'; +import { Link, useNouter } from '../services/nouter'; import { pluralForm } from '../utils'; import CreatePost from './create-post'; @@ -7,17 +8,22 @@ import ErrorBoundary from './error-boundary'; import { UserProfileHead } from './user-profile-head'; import { SubscriptionRequestsAlert } from './susbscription-requests-alert'; -export default function UserProfile(props) { - const authenticated = useSelector((state) => state.authenticated); - const groupRequestsCount = - props.type === 'group' && props.authenticated - ? (props.managedGroups.find((g) => g.id === props.id) || { requests: [] }).requests.length - : 0; +export default function UserProfile({ allowToPost, noPostLines = false }) { + const { + authenticated, + isLoading, + isItMe, + foundUser, + canIPostHere, + whyCannotPost, + groupRequestsCount, + sendTo, + } = useUserProfileData(allowToPost); return (
    - {props.isItMe && !props.isLoading ? : false} + {isItMe && !isLoading ? : false} {groupRequestsCount > 0 && (

    @@ -30,19 +36,74 @@ export default function UserProfile(props) { )} + {!noPostLines ? ( + <> + {canIPostHere && } - {props.canIPostHere && ( - - )} - - {props.whyCannotPost &&

    {props.whyCannotPost}

    } + {whyCannotPost &&

    {whyCannotPost}

    } - {authenticated && !props.canIPostHere && props.isRestricted === '1' && ( -
    - Only administrators can post to this group. -
    - )} + {authenticated && !canIPostHere && foundUser?.isRestricted === '1' && ( +
    + Only administrators can post to this group. +
    + )} + + ) : null}
    ); } + +function useUserProfileData(allowToPost) { + const { params } = useNouter(); + const username = params.userName.toLowerCase(); + + const authenticated = useSelector((state) => state.authenticated); + const currentUser = useSelector((state) => state.user); + const isLoading = useSelector((state) => state.routeLoadingState); + const managedGroups = useSelector((state) => state.managedGroups); + const sendToBase = useSelector((state) => state.sendTo); + + const foundUser = useSelector((state) => + Object.values(state.users).find((u) => u.username === username), + ); + + const isItMe = foundUser ? foundUser.username === currentUser.username : false; + + const amIGroupAdmin = + authenticated && + foundUser && + foundUser.type === 'group' && + (foundUser.administrators || []).includes(currentUser.id); + + const subscribed = authenticated && foundUser && currentUser.subscriptions.includes(foundUser.id); + const shouldIPostToGroup = subscribed && (foundUser.isRestricted === '0' || amIGroupAdmin); + + const canIPostHere = (foundUser?.youCan.includes('post') ?? false) && allowToPost; + + const whyCannotPost = + shouldIPostToGroup && foundUser.theyDid.includes('block') + ? 'You are blocked in this group' + : null; + + const groupRequestsCount = + foundUser?.type === 'group' && authenticated + ? (managedGroups.find((g) => g.id === foundUser.id) || { requests: [] }).requests.length + : 0; + + const sendTo = useMemo( + () => ({ ...sendToBase, defaultFeed: foundUser ? foundUser.username : null }), + [sendToBase, foundUser], + ); + + return { + authenticated, + isLoading, + isItMe, + foundUser, + canIPostHere, + whyCannotPost, + groupRequestsCount, + sendTo, + }; +} diff --git a/src/components/user.jsx b/src/components/user.jsx index 50ca52766..75dd1b965 100644 --- a/src/components/user.jsx +++ b/src/components/user.jsx @@ -5,16 +5,10 @@ import { connect } from 'react-redux'; import * as _ from 'lodash-es'; import { inject as injectParams } from 'regexparam'; -import { - createPost, - resetPostCreateForm, - getUserInfo, - togglePinnedGroup, -} from '../redux/action-creators'; import { initialAsyncState } from '../redux/async-helpers'; import { apiVersion } from '../services/api-version'; import { withNouter } from '../services/nouter'; -import { postActions, userActions } from './select-utils'; +import { postActions } from './select-utils'; import FeedOptionsSwitch from './feed-options-switch'; import Breadcrumbs from './breadcrumbs'; import ErrorBoundary from './error-boundary'; @@ -92,18 +86,7 @@ const UserHandler = (props) => {
    {props.breadcrumbs.shouldShowBreadcrumbs ? : false} - +
    {showContent ? ( @@ -197,8 +180,6 @@ function selectState(state, ownProps) { breadcrumb: currentRouteName.replace('user', ''), }; - const sendTo = { ...state.sendTo, defaultFeed: foundUser ? foundUser.username : null }; - const showSummaryHeader = currentRouteName === 'userSummary'; return { @@ -208,19 +189,12 @@ function selectState(state, ownProps) { showSummaryHeader, viewUser, breadcrumbs, - sendTo, }; } function selectActions(dispatch) { return { ...postActions(dispatch), - createPost: (feeds, postText, attachmentIds, more) => - dispatch(createPost(feeds, postText, attachmentIds, more)), - resetPostCreateForm: (...args) => dispatch(resetPostCreateForm(...args)), - userActions: userActions(dispatch), - getUserInfo: (username) => dispatch(getUserInfo(username)), - togglePinnedGroup: ({ id }) => dispatch(togglePinnedGroup(id)), }; } diff --git a/src/redux/route-actions.js b/src/redux/route-actions.js index db1a5da20..49e1ccd1c 100644 --- a/src/redux/route-actions.js +++ b/src/redux/route-actions.js @@ -63,6 +63,10 @@ export const routeActions = { getUserFeed(next.params.userName, getOffset(next)), getUserStats(next.params.userName), ], + userMedia: (next) => [ + getSearch(`in:${next.params.userName} has:image,video`, getOffset(next)), + getUserStats(next.params.userName), + ], userComments: (next) => getUserComments(next.params.userName, getOffset(next)), userLikes: (next) => getUserLikes(next.params.userName, getOffset(next)), userSummary: (next) => getUserSummary(next.params.userName, next.params.days), diff --git a/src/services/lightbox-actual.js b/src/services/lightbox-actual.js index 81c3dc992..71b83fb02 100644 --- a/src/services/lightbox-actual.js +++ b/src/services/lightbox-actual.js @@ -9,13 +9,14 @@ import { getFullscreenAPI } from '../utils/fullscreen'; import { isGifLike } from '../components/post/attachments/visual/utils'; import { intentToScroll } from './unscroll'; import { handlePip } from './pip-video'; +import { NEEDMORE_EVENT, MOREITEMS_EVENT } from './lightbox-events'; const prevHotKeys = ['a', 'ф', 'h', 'р', '4']; const nextHotKeys = ['d', 'в', 'k', 'л', '6']; const fullScreenHotKeys = ['f', 'а']; -export function openLightbox(index, dataSource) { - initLightbox().loadAndOpen(index, dataSource); +export function openLightbox(index, dataSource, options) { + initLightbox(options).loadAndOpen(index, dataSource); } const fullScreenAPI = getFullscreenAPI(); @@ -38,7 +39,9 @@ const downloadIconHtml = { outlineID: 'pswp__icn-download', }; -function initLightbox() { +const paginationThreshold = 3; + +function initLightbox({ loop = true, pagination = false } = {}) { const lightbox = new PhotoSwipeLightbox({ clickToCloseNonZoomable: false, tapAction(_, event) { @@ -54,6 +57,7 @@ function initLightbox() { maxZoomLevel: 2, pswpModule, returnFocus: false, + loop, }); new PhotoSwipeVideoPlugin(lightbox, {}); @@ -112,6 +116,29 @@ function initLightbox() { }); }); + // Add caption element + lightbox.on('uiRegister', () => { + lightbox.pswp.ui.registerElement({ + name: 'custom-caption', + order: 9, + isButton: false, + appendTo: 'root', + html: '', + onInit: (el, pswp) => { + pswp.on('change', () => { + const caption = pswp.currSlide.data.caption; + if (caption) { + el.innerHTML = caption; + el.style.display = ''; + } else { + el.innerHTML = ''; + el.style.display = 'none'; + } + }); + }, + }); + }); + lightbox.on('bindEvents', () => { const h = (e) => { if (e.ctrlKey || e.metaKey) { @@ -144,6 +171,11 @@ function initLightbox() { }); // Handle back button + // Push a "marker" history entry so pressing Back closes the lightbox. + // We preserve the history library's state (which contains the location key) + // so that popping this marker doesn't look like a new navigation to the router. + const pushMarker = () => history.pushState(window.history.state, ''); + let closedByNavigation = false; const close = () => { lightbox.pswp.close(); @@ -151,7 +183,7 @@ function initLightbox() { }; lightbox.on('beforeOpen', () => { window.addEventListener('popstate', close); - history.pushState(null, ''); + pushMarker(); }); lightbox.on('destroy', () => { window.removeEventListener('popstate', close); @@ -160,6 +192,56 @@ function initLightbox() { } }); + // Pagination: request more items when approaching the last slide + if (pagination) { + let isLastPage = false; + let needmoreDispatched = false; + + lightbox.on('beforeOpen', () => { + const onMoreItems = (e) => { + const { items, isLastPage: last } = e.detail; + isLastPage = last; + needmoreDispatched = false; + + const pswp = lightbox.pswp; + const dataSource = pswp.options.dataSource; + const existingPids = new Set(dataSource.map((d) => d.pid)); + const newItems = items.filter((item) => !existingPids.has(item.pid)); + if (newItems.length > 0) { + const firstNewIndex = dataSource.length; + dataSource.push(...newItems); + // refreshSlideContent loads nearby slides and dispatches 'change', + // which updates the counter and navigation arrows. + pswp.refreshSlideContent(firstNewIndex); + } + + // Restore the history marker after navigation (replaceState removed it) + pushMarker(); + }; + + document.addEventListener(MOREITEMS_EVENT, onMoreItems); + lightbox.on('destroy', () => document.removeEventListener(MOREITEMS_EVENT, onMoreItems)); + }); + + const checkNeedMore = () => { + if (isLastPage || needmoreDispatched) { + return; + } + const total = lightbox.pswp.getNumItems(); + const curr = lightbox.pswp.currIndex; + if (curr >= total - paginationThreshold) { + needmoreDispatched = true; + document.dispatchEvent(new CustomEvent(NEEDMORE_EVENT)); + } + }; + + lightbox.on('bindEvents', () => { + lightbox.pswp.on('change', checkNeedMore); + // 'change' does not fire on initial open, so check explicitly + checkNeedMore(); + }); + } + // Looking for video in active slide let currentVideo = null; lightbox.on('contentActivate', ({ content }) => { diff --git a/src/services/lightbox-events.js b/src/services/lightbox-events.js new file mode 100644 index 000000000..f648e97dd --- /dev/null +++ b/src/services/lightbox-events.js @@ -0,0 +1,5 @@ +// Custom DOM events for communication between the lightbox and React components. +// The lightbox dispatches NEEDMORE when approaching the last slide, +// and listens for MOREITEMS with new slide data from the page component. +export const NEEDMORE_EVENT = 'lightbox:needmore'; +export const MOREITEMS_EVENT = 'lightbox:moreitems'; diff --git a/src/services/lightbox.js b/src/services/lightbox.js index 935f34c48..6d5952e78 100644 --- a/src/services/lightbox.js +++ b/src/services/lightbox.js @@ -1,14 +1,14 @@ /* eslint-disable no-console */ let firstOpen = true; -export function openLightbox(index, dataSource) { +export function openLightbox(index, dataSource, options) { if (firstOpen && dataSource[index].src) { // Preload image new Image().src = dataSource[index].src; } firstOpen = false; import('./lightbox-actual') - .then((m) => m.openLightbox(index, dataSource)) + .then((m) => m.openLightbox(index, dataSource, options)) .catch((e) => console.error('Could not load lightbox', e)); } diff --git a/styles/shared/lighbox.scss b/styles/shared/lighbox.scss index 747390820..21647706e 100644 --- a/styles/shared/lighbox.scss +++ b/styles/shared/lighbox.scss @@ -97,3 +97,39 @@ align-items: center; justify-content: center; } + +.pswp__custom-caption { + position: absolute; + left: 50%; + bottom: 1rem; + transform: translateX(-50%); + max-width: min(30rem, 90vw); + width: calc(100% - 2rem); + padding: 0.25em 0.75em; + border-radius: 0.25rem; + background: rgb(0, 0, 0, 0.65); + color: #fff; + text-align: center; + display: flex; + justify-content: center; +} + +.pswp-caption__link { + color: #fff; + text-decoration: none; + display: block; + + &:hover, + &:active, + &:focus { + color: #fff; + text-decoration: underline; + } +} + +.pswp-caption__avatar { + border-radius: 50%; + vertical-align: middle; + width: 1.5em; + height: 1.5em; +} diff --git a/test/jest/__snapshots__/user-profile-head.test.jsx.snap b/test/jest/__snapshots__/user-profile-head.test.jsx.snap index e24b43300..187b0ce8c 100644 --- a/test/jest/__snapshots__/user-profile-head.test.jsx.snap +++ b/test/jest/__snapshots__/user-profile-head.test.jsx.snap @@ -198,6 +198,17 @@ exports[`UserProfileHead > Correctly displays that we are mutually subscribed 1`
  • + +