From 1fbc735bd7f4231127d97186378b69c5d6534f80 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 16:58:39 +0300 Subject: [PATCH 01/20] Make the UserProfile component autonomous --- src/components/user-profile.jsx | 84 ++++++++++++++++++++++++++++----- src/components/user.jsx | 30 +----------- 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/src/components/user-profile.jsx b/src/components/user-profile.jsx index da1201be6..8f03f34a6 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 }) { + const { + authenticated, + isLoading, + isItMe, + foundUser, + canIPostHere, + whyCannotPost, + groupRequestsCount, + sendTo, + } = useUserProfileData(allowToPost); return (
- {props.isItMe && !props.isLoading ? : false} + {isItMe && !isLoading ? : false} {groupRequestsCount > 0 && (

@@ -31,13 +37,11 @@ export default function UserProfile(props) { - {props.canIPostHere && ( - - )} + {canIPostHere && } - {props.whyCannotPost &&

{props.whyCannotPost}

} + {whyCannotPost &&

{whyCannotPost}

} - {authenticated && !props.canIPostHere && props.isRestricted === '1' && ( + {authenticated && !canIPostHere && foundUser?.isRestricted === '1' && (
Only administrators can post to this group.
@@ -46,3 +50,57 @@ export default function UserProfile(props) {
); } + +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)), }; } From f528e5509cd4f1c1a889e895061c77419ad2171b Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 18:03:20 +0300 Subject: [PATCH 02/20] Extract getPostRecipients and isPostNSFW helper functions --- src/components/select-utils.js | 38 +++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 14 deletions(-) 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. From 16181c8e46d3cb2a2d43fc43133b70734adaced7 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 18:03:43 +0300 Subject: [PATCH 03/20] Use per-attachment NSFW flag with fallback to post-level flag --- src/components/post/attachments/visual/container-static.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/post/attachments/visual/container-static.jsx b/src/components/post/attachments/visual/container-static.jsx index 7be52f1ef..660d55bb0 100644 --- a/src/components/post/attachments/visual/container-static.jsx +++ b/src/components/post/attachments/visual/container-static.jsx @@ -89,7 +89,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} From 75fe7332c3c08b5b0e449b760a53eb1543dfadec Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 18:48:00 +0300 Subject: [PATCH 04/20] Add a simple "All Media" page for users/groups --- src/app.jsx | 7 +++ src/components/user-media.jsx | 81 +++++++++++++++++++++++++++++++++ src/components/user-profile.jsx | 25 +++++----- src/redux/route-actions.js | 4 ++ 4 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 src/components/user-media.jsx 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/user-media.jsx b/src/components/user-media.jsx new file mode 100644 index 000000000..98ed7d0d3 --- /dev/null +++ b/src/components/user-media.jsx @@ -0,0 +1,81 @@ +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 { useMemo } from 'react'; +import { VisualContainer } from './post/attachments/visual/container'; +import { isPostNSFW } from './select-utils'; + +const tokenizeHashtags = hashtags(); + +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 attachments = useMediaAttachments(foundUser); + + return ( +
+
+
+ +
+
+
+ + +
+ + + +
+ ); +} + +function useMediaAttachments(foundUser) { + // 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)); + } + } + + return feedPostIds + .flatMap((postId) => { + const post = allPosts[postId]; + const nsfw = nsfwByPostId.get(postId) ?? false; + return (post?.attachments || []).map((attId) => ({ attId, nsfw })); + }) + .map(({ attId, nsfw }) => { + const att = allAttachments[attId]; + return att ? { ...att, isNSFW: nsfw } : null; + }) + .filter((att) => att && (att.mediaType === 'image' || att.mediaType === 'video')); + }, [feedPostIds, allPosts, allAttachments, isNSFWVisible, foundUser, store]); +} diff --git a/src/components/user-profile.jsx b/src/components/user-profile.jsx index 8f03f34a6..82a008033 100644 --- a/src/components/user-profile.jsx +++ b/src/components/user-profile.jsx @@ -8,7 +8,7 @@ import ErrorBoundary from './error-boundary'; import { UserProfileHead } from './user-profile-head'; import { SubscriptionRequestsAlert } from './susbscription-requests-alert'; -export default function UserProfile({ allowToPost }) { +export default function UserProfile({ allowToPost, noPostLines = false }) { const { authenticated, isLoading, @@ -36,16 +36,19 @@ export default function UserProfile({ allowToPost }) { )} - - {canIPostHere && } - - {whyCannotPost &&

{whyCannotPost}

} - - {authenticated && !canIPostHere && foundUser?.isRestricted === '1' && ( -
- Only administrators can post to this group. -
- )} + {!noPostLines ? ( + <> + {canIPostHere && } + + {whyCannotPost &&

{whyCannotPost}

} + + {authenticated && !canIPostHere && foundUser?.isRestricted === '1' && ( +
+ Only administrators can post to this group. +
+ )} + + ) : null} ); 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), From b2a47f949f3b03d4ff52320a8b75a2f6ebe0558f Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 19:03:58 +0300 Subject: [PATCH 05/20] Add NSFW toggle to user media gallery with persistent state --- src/components/user-media.jsx | 44 ++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx index 98ed7d0d3..e5737ef33 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -5,19 +5,32 @@ import FeedOptionsSwitch from './feed-options-switch'; import UserProfile from './user-profile'; import { useNouter } from '../services/nouter'; import PaginatedView from './paginated-view'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { VisualContainer } from './post/attachments/visual/container'; import { isPostNSFW } from './select-utils'; const tokenizeHashtags = hashtags(); +// 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 attachments = useMediaAttachments(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); return (
@@ -30,6 +43,18 @@ export default function UserMedia() {
+ {hasNSFW && ( +
+ +
+ )} @@ -37,7 +62,7 @@ export default function UserMedia() { ); } -function useMediaAttachments(foundUser) { +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 @@ -66,10 +91,15 @@ function useMediaAttachments(foundUser) { } } - return feedPostIds + let hasNSFW = false; + const attachments = feedPostIds .flatMap((postId) => { const post = allPosts[postId]; - const nsfw = nsfwByPostId.get(postId) ?? false; + const isNSFW = nsfwByPostId.get(postId) ?? false; + if (isNSFW) { + hasNSFW = true; + } + const nsfw = isNSFW && !showNSFW; return (post?.attachments || []).map((attId) => ({ attId, nsfw })); }) .map(({ attId, nsfw }) => { @@ -77,5 +107,7 @@ function useMediaAttachments(foundUser) { return att ? { ...att, isNSFW: nsfw } : null; }) .filter((att) => att && (att.mediaType === 'image' || att.mediaType === 'video')); - }, [feedPostIds, allPosts, allAttachments, isNSFWVisible, foundUser, store]); + + return { attachments, hasNSFW }; + }, [feedPostIds, allPosts, allAttachments, isNSFWVisible, showNSFW, foundUser, store]); } From dc67320d9e0db0f8aa358c017ccc2988cc9f2726 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 19:11:56 +0300 Subject: [PATCH 06/20] Add "Media only" link to user profile stats section --- src/components/user-profile-head-stats.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/user-profile-head-stats.jsx b/src/components/user-profile-head-stats.jsx index 416fb5958..372264bfd 100644 --- a/src/components/user-profile-head-stats.jsx +++ b/src/components/user-profile-head-stats.jsx @@ -85,6 +85,9 @@ export const UserProfileHeadStats = ({ user, canFollowStatLinks }) => { canFollow={canFollowStatLinks} maxDigits={maxDigits} /> + + Media only + )}
  • From 780d20feb13b7d12a5b0e77bf01bd93a32fbbc0a Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 20:00:50 +0300 Subject: [PATCH 07/20] Add a primitive caption support for lightbox images --- .../post/attachments/visual/hooks.js | 1 + src/components/user-media.jsx | 6 ++--- src/services/lightbox-actual.js | 23 +++++++++++++++++++ styles/shared/lighbox.scss | 14 +++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/components/post/attachments/visual/hooks.js b/src/components/post/attachments/visual/hooks.js index 57c2f32f9..7530c0e20 100644 --- a/src/components/post/attachments/visual/hooks.js +++ b/src/components/post/attachments/visual/hooks.js @@ -63,6 +63,7 @@ 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], ); diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx index e5737ef33..e747d528d 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -100,11 +100,11 @@ function useMediaAttachments(foundUser, showNSFW) { hasNSFW = true; } const nsfw = isNSFW && !showNSFW; - return (post?.attachments || []).map((attId) => ({ attId, nsfw })); + return (post?.attachments || []).map((attId) => ({ postId, attId, nsfw })); }) - .map(({ attId, nsfw }) => { + .map(({ postId, attId, nsfw }) => { const att = allAttachments[attId]; - return att ? { ...att, isNSFW: nsfw } : null; + return att ? { ...att, isNSFW: nsfw, caption: `Post: ${postId}` } : null; }) .filter((att) => att && (att.mediaType === 'image' || att.mediaType === 'video')); diff --git a/src/services/lightbox-actual.js b/src/services/lightbox-actual.js index 81c3dc992..ff8015489 100644 --- a/src/services/lightbox-actual.js +++ b/src/services/lightbox-actual.js @@ -112,6 +112,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) { diff --git a/styles/shared/lighbox.scss b/styles/shared/lighbox.scss index 747390820..ad5918425 100644 --- a/styles/shared/lighbox.scss +++ b/styles/shared/lighbox.scss @@ -97,3 +97,17 @@ 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; +} From 091f543ba70eb56d03561790e8747a3271bb7632 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 20:51:08 +0300 Subject: [PATCH 08/20] Enhance lightbox captions with author info and post excerpt --- src/components/user-media.jsx | 31 ++++++++++++++++++++++++++++++- styles/shared/lighbox.scss | 17 +++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx index e747d528d..87f2db4bb 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -8,6 +8,7 @@ import PaginatedView from './paginated-view'; import { useMemo, useState } from 'react'; import { VisualContainer } from './post/attachments/visual/container'; import { isPostNSFW } from './select-utils'; +import { htmlSafe } from '../utils'; const tokenizeHashtags = hashtags(); @@ -104,10 +105,38 @@ function useMediaAttachments(foundUser, showNSFW) { }) .map(({ postId, attId, nsfw }) => { const att = allAttachments[attId]; - return att ? { ...att, isNSFW: nsfw, caption: `Post: ${postId}` } : null; + 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); + + return ( + `` + + ` ` + + `${username}` + + (body ? `: ${body}` : '') + + `` + ); +} diff --git a/styles/shared/lighbox.scss b/styles/shared/lighbox.scss index ad5918425..4c6061a22 100644 --- a/styles/shared/lighbox.scss +++ b/styles/shared/lighbox.scss @@ -111,3 +111,20 @@ color: #fff; text-align: center; } + +.pswp-caption__link { + color: #fff; + text-decoration: none; + + &:hover { + color: #fff; + text-decoration: underline; + } +} + +.pswp-caption__avatar { + border-radius: 50%; + vertical-align: middle; + width: 1.5em; + height: 1.5em; +} From a220759c9f7a9e0fab35041bfc8c29d69f27ebfd Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 21:16:27 +0300 Subject: [PATCH 09/20] Use SPA navigation for lightbox caption clicks --- src/components/layout.jsx | 2 + src/components/lightbox-link-interceptor.jsx | 42 ++++++++++++++++++++ src/components/user-media.jsx | 2 + 3 files changed, 46 insertions(+) create mode 100644 src/components/lightbox-link-interceptor.jsx 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/user-media.jsx b/src/components/user-media.jsx index 87f2db4bb..2b53ec4b3 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -132,6 +132,8 @@ function buildCaption(author, post, postId) { 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 ( `` + ` ` + From 23814db820bb7f5df0827a2c9865f6dfaca06666 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 21:23:56 +0300 Subject: [PATCH 10/20] Center lightbox caption content using flexbox --- styles/shared/lighbox.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/styles/shared/lighbox.scss b/styles/shared/lighbox.scss index 4c6061a22..f928455b3 100644 --- a/styles/shared/lighbox.scss +++ b/styles/shared/lighbox.scss @@ -110,11 +110,14 @@ 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 { color: #fff; From 6cb9078fbdc62595d07a003249010acd95980409 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 21:46:52 +0300 Subject: [PATCH 11/20] Add privacy checks to user media gallery page --- src/components/user-media.jsx | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx index 2b53ec4b3..be21cf8c7 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -22,6 +22,7 @@ export default function UserMedia() { 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 }; @@ -44,7 +45,7 @@ export default function UserMedia() { - {hasNSFW && ( + {canViewAccountContent && hasNSFW && (
    )} - - - + {canViewAccountContent ? ( + + + + ) : ( + foundUser && ( +
    +

    Media is not available for this account.

    +
    + ) + )} ); } +// 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 From 5118993343f78f45ad9966b5d05bee9b729e76e6 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 22:10:53 +0300 Subject: [PATCH 12/20] Add page title for user media gallery --- src/components/user-media.jsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx index be21cf8c7..d9e92e6b2 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -1,3 +1,4 @@ +/* global CONFIG */ import { useSelector, useStore } from 'react-redux'; import { hashtags } from 'social-text-tokenizer'; import Breadcrumbs from './breadcrumbs'; @@ -9,6 +10,7 @@ import { 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'; const tokenizeHashtags = hashtags(); @@ -34,8 +36,22 @@ export default function UserMedia() { }; const { attachments, hasNSFW } = useMediaAttachments(foundUser, showNSFW); + const nameForTitle = useMemo( + () => + foundUser.username === foundUser.screenName + ? foundUser.username + : `${foundUser.screenName} (${foundUser.username})`, + [foundUser.screenName, foundUser.username], + ); + return (
    + + + {nameForTitle} - All Media - {CONFIG.siteTitle} + + +
    From 6747dd55a61e7ac8184b972d0bd473fda5981719 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 22:11:37 +0300 Subject: [PATCH 13/20] Align "All media" link with other stats --- src/components/user-profile-head-stats.jsx | 14 +++++++++++--- src/components/user-profile-head.module.scss | 8 ++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/user-profile-head-stats.jsx b/src/components/user-profile-head-stats.jsx index 372264bfd..af2df6463 100644 --- a/src/components/user-profile-head-stats.jsx +++ b/src/components/user-profile-head-stats.jsx @@ -85,9 +85,17 @@ export const UserProfileHeadStats = ({ user, canFollowStatLinks }) => { canFollow={canFollowStatLinks} maxDigits={maxDigits} /> - - Media only - + {canFollowStatLinks ? ( +
  • + + All media + +
  • + ) : null} )}
  • diff --git a/src/components/user-profile-head.module.scss b/src/components/user-profile-head.module.scss index 601bffdcb..5f0e0f038 100644 --- a/src/components/user-profile-head.module.scss +++ b/src/components/user-profile-head.module.scss @@ -129,6 +129,14 @@ $actions-padding: 0.75em; .allPosts { 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) { From 4ec39eb8d99a4fd1ecb8ceed102c0bc2cb17e403 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 22:21:40 +0300 Subject: [PATCH 14/20] Fix capitalization and styling inconsistencies in user media UI --- src/components/user-media.jsx | 4 ++-- src/components/user-profile-head-stats.jsx | 2 +- src/components/user-profile-head.module.scss | 7 ++++++- styles/shared/lighbox.scss | 4 +++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx index d9e92e6b2..a897446d8 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -48,7 +48,7 @@ export default function UserMedia() {
    - {nameForTitle} - All Media - {CONFIG.siteTitle} + {nameForTitle} - All media - {CONFIG.siteTitle} @@ -58,7 +58,7 @@ export default function UserMedia() {
    - +
    {canViewAccountContent && hasNSFW && ( diff --git a/src/components/user-profile-head-stats.jsx b/src/components/user-profile-head-stats.jsx index af2df6463..63d03af3d 100644 --- a/src/components/user-profile-head-stats.jsx +++ b/src/components/user-profile-head-stats.jsx @@ -98,7 +98,7 @@ export const UserProfileHeadStats = ({ user, canFollowStatLinks }) => { ) : null} )} -
  • +
  • Since{' '} diff --git a/src/components/user-profile-head.module.scss b/src/components/user-profile-head.module.scss index 5f0e0f038..ccef50178 100644 --- a/src/components/user-profile-head.module.scss +++ b/src/components/user-profile-head.module.scss @@ -126,7 +126,8 @@ $actions-padding: 0.75em; white-space: nowrap; } - .allPosts { + .allPosts, + .sinceDate { margin-top: 0.5em; } @@ -160,6 +161,10 @@ $actions-padding: 0.75em; text-transform: lowercase; } + .sinceDate { + margin-top: 0; + } + .registeredOn, .invitedBy, .moreDetails { diff --git a/styles/shared/lighbox.scss b/styles/shared/lighbox.scss index f928455b3..21647706e 100644 --- a/styles/shared/lighbox.scss +++ b/styles/shared/lighbox.scss @@ -119,7 +119,9 @@ text-decoration: none; display: block; - &:hover { + &:hover, + &:active, + &:focus { color: #fff; text-decoration: underline; } From e4ec02bf96cb2b8f361e20c43994675fbc7905f5 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 22:30:01 +0300 Subject: [PATCH 15/20] Add empty state message when user has no media attachments --- src/components/user-media.jsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/user-media.jsx b/src/components/user-media.jsx index a897446d8..348c881b9 100644 --- a/src/components/user-media.jsx +++ b/src/components/user-media.jsx @@ -74,9 +74,15 @@ export default function UserMedia() { )} {canViewAccountContent ? ( - - - + attachments.length > 0 ? ( + + + + ) : ( +
    +

    There is no media in this account.

    +
    + ) ) : ( foundUser && (
    From 05c43f9a0c075b27d9fa0c5f69d2407ee5879089 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Fri, 6 Mar 2026 23:27:27 +0300 Subject: [PATCH 16/20] Update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) 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 From ce86493a45ebf276d46dc7f8a93e1f79ed5e4290 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 7 Mar 2026 09:37:57 +0300 Subject: [PATCH 17/20] Update snapshots for user profile "All media" link addition --- .../user-profile-head.test.jsx.snap | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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`
  • + +