Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1fbc735
Make the UserProfile component autonomous
davidmz Mar 6, 2026
f528e55
Extract getPostRecipients and isPostNSFW helper functions
davidmz Mar 6, 2026
16181c8
Use per-attachment NSFW flag with fallback to post-level flag
davidmz Mar 6, 2026
75fe733
Add a simple "All Media" page for users/groups
davidmz Mar 6, 2026
b2a47f9
Add NSFW toggle to user media gallery with persistent state
davidmz Mar 6, 2026
dc67320
Add "Media only" link to user profile stats section
davidmz Mar 6, 2026
780d20f
Add a primitive caption support for lightbox images
davidmz Mar 6, 2026
091f543
Enhance lightbox captions with author info and post excerpt
davidmz Mar 6, 2026
a220759
Use SPA navigation for lightbox caption clicks
davidmz Mar 6, 2026
23814db
Center lightbox caption content using flexbox
davidmz Mar 6, 2026
6cb9078
Add privacy checks to user media gallery page
davidmz Mar 6, 2026
5118993
Add page title for user media gallery
davidmz Mar 6, 2026
6747dd5
Align "All media" link with other stats
davidmz Mar 6, 2026
4ec39eb
Fix capitalization and styling inconsistencies in user media UI
davidmz Mar 6, 2026
e4ec02b
Add empty state message when user has no media attachments
davidmz Mar 6, 2026
05c43f9
Update CHANGELOG
davidmz Mar 6, 2026
ce86493
Update snapshots for user profile "All media" link addition
davidmz Mar 7, 2026
ba0d3d8
Pass lightbox options through visual container components to disable …
davidmz Mar 7, 2026
d65248a
Add pagination support to user media gallery lightbox
davidmz Mar 7, 2026
0ccfd39
Trigger pagination check on initial lightbox open to load more items …
davidmz Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -419,6 +420,12 @@ function App() {
component={User}
{...generateRouteHooks(boundRouteActions('userLikes'))}
/>
<Route
name="userMedia"
path="/:userName/media"
component={UserMedia}
{...generateRouteHooks(boundRouteActions('userMedia'))}
/>
<Route path="/.well-known/change-password">
<Redirect to="/settings/sign-in" />
</Route>
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<Delayed>
Expand Down Expand Up @@ -153,6 +154,7 @@ class Layout extends Component {
<UIScaleSetter />
<RtlTextSetter />
<SVGSymbolDeclarations />
<LightboxLinkInterceptor />

<LayoutHeader />

Expand Down
42 changes: 42 additions & 0 deletions src/components/lightbox-link-interceptor.jsx
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Expand Down
5 changes: 3 additions & 2 deletions src/components/post/attachments/visual/container-static.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 3 additions & 2 deletions src/components/post/attachments/visual/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
);
}
Expand Down
38 changes: 24 additions & 14 deletions src/components/select-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading