+
+
+ {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 (
+ `