From 67c27619b45237ec565e98b58446f41bca23f546 Mon Sep 17 00:00:00 2001 From: Roman Ivanov Date: Sun, 10 Aug 2025 21:05:53 +0300 Subject: [PATCH 1/6] feat(posts): allow user to pin own posts to top of profile feed --- src/components/feed.jsx | 19 ++++++++++++++++++- src/components/post/post-more-link.jsx | 2 ++ src/components/post/post-more-menu.jsx | 18 +++++++++++++++++- src/components/post/post.jsx | 6 ++++++ src/components/select-utils.js | 4 ++++ src/components/user-feed.jsx | 2 +- src/redux/action-creators.js | 16 ++++++++++++++++ src/redux/action-types.js | 2 ++ src/redux/reducers/posts.js | 26 ++++++++++++++++++++++++++ src/services/api.js | 9 +++++++++ 10 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/components/feed.jsx b/src/components/feed.jsx index 0776495cc..f23f95c4a 100644 --- a/src/components/feed.jsx +++ b/src/components/feed.jsx @@ -86,7 +86,7 @@ class Feed extends PureComponent { const postIsHidden = (post) => !!(post.isHidden || post.hiddenByCriteria); export default connect( - (state) => { + (state, ownProps) => { const { entries, recentlyHiddenEntries, isHiddenRevealed, feedError, feedRequestType } = state.feedViewState; @@ -106,6 +106,21 @@ export default connect( hiddenPosts = allPosts.filter((p) => postIsHidden(p)); } + // If we're on a user feed, lift pinned posts to the top while preserving + // the original order within pinned/non-pinned groups. + if (ownProps.isInUserFeed) { + const withIndex = visiblePosts.map((p, i) => ({ p, i })); + withIndex.sort((a, b) => { + const ap = a.p.isPinned ? 1 : 0; + const bp = b.p.isPinned ? 1 : 0; + if (ap !== bp) { + return bp - ap; + } + return a.i - b.i; + }); + visiblePosts = withIndex.map(({ p }) => p); + } + return { loading: state.routeLoadingState, emptyFeed: entries.length === 0, @@ -150,6 +165,8 @@ function FeedEntry({ post, section, ...props }) { cancelEditingPost={props.cancelEditingPost} saveEditingPost={props.saveEditingPost} deletePost={props.deletePost} + pinPost={props.pinPost} + unpinPost={props.unpinPost} addAttachmentResponse={props.addAttachmentResponse} toggleCommenting={props.toggleCommenting} addComment={props.addComment} diff --git a/src/components/post/post-more-link.jsx b/src/components/post/post-more-link.jsx index 84ec6fa78..a4db8bfb6 100644 --- a/src/components/post/post-more-link.jsx +++ b/src/components/post/post-more-link.jsx @@ -70,6 +70,8 @@ export default function PostMoreLink({ post, user, ...props }) { enableComments={props.enableComments} disableComments={props.disableComments} deletePost={deletePost} + pinPost={props.pinPost} + unpinPost={props.unpinPost} doAndClose={doAndClose} doAndForceClose={doAndForceClose} permalink={canonicalPostURI} diff --git a/src/components/post/post-more-menu.jsx b/src/components/post/post-more-menu.jsx index 455cee454..9dedf993a 100644 --- a/src/components/post/post-more-menu.jsx +++ b/src/components/post/post-more-menu.jsx @@ -1,7 +1,7 @@ import { forwardRef, useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { Link } from 'react-router'; import cn from 'classnames'; -import { faLink, faEdit, faSignOutAlt, faAt } from '@fortawesome/free-solid-svg-icons'; +import { faLink, faEdit, faSignOutAlt, faAt, faThumbtack } from '@fortawesome/free-solid-svg-icons'; import { faClock, faCommentDots, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; import { noop } from 'lodash-es'; import { useDispatch } from 'react-redux'; @@ -29,6 +29,7 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( isDeletable = false, isModeratingComments = false, commentsDisabled = false, + isPinned = false, createdAt, updatedAt, createdBy: postCreatedBy, @@ -39,6 +40,8 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( enableComments, disableComments, deletePost, + pinPost, + unpinPost, doAndClose, doAndForceClose, permalink, @@ -84,6 +87,19 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( ), ], [ + isOwnPost && ( +
+ {isPinned ? ( + unpinPost(postId))}> + Unpin from profile + + ) : ( + pinPost(postId))}> + Pin to profile + + )} +
+ ), isEditable && (
diff --git a/src/components/post/post.jsx b/src/components/post/post.jsx index 8bdaaf971..c0ee09960 100644 --- a/src/components/post/post.jsx +++ b/src/components/post/post.jsx @@ -27,6 +27,7 @@ import TimeDisplay from '../time-display'; import LinkPreview from '../link-preview/preview'; import ErrorBoundary from '../error-boundary'; import { Icon } from '../fontawesome-icons'; +import { faThumbtack } from '@fortawesome/free-solid-svg-icons'; import { UserPicture } from '../user-picture'; import { prepareAsyncFocus } from '../../utils/prepare-async-focus'; @@ -286,6 +287,8 @@ class Post extends Component { disableComments={this.disableComments} enableComments={this.enableComments} deletePost={this.handleDeletePost} + pinPost={this.props.pinPost} + unpinPost={this.props.unpinPost} toggleSave={this.toggleSave} handleMentionAuthor={this.handleMentionAuthorClick} /> @@ -337,6 +340,9 @@ class Post extends Component { absolute={this.state.forceAbsTimestamps || null} /> + {props.isPinned && ( + + )} {props.commentsDisabled && ( diff --git a/src/components/select-utils.js b/src/components/select-utils.js index e9d1b8565..a4b7b0c83 100644 --- a/src/components/select-utils.js +++ b/src/components/select-utils.js @@ -25,6 +25,8 @@ import { cancelEditingPost, saveEditingPost, deletePost, + pinPost, + unpinPost, // Comment actions toggleCommenting, @@ -216,6 +218,8 @@ export function postActions(dispatch) { dispatch(addComment(postId, commentText, draftKey)), likePost: (postId, userId) => dispatch(likePost(postId, userId)), unlikePost: (postId, userId) => dispatch(unlikePost(postId, userId)), + pinPost: (postId) => dispatch(pinPost(postId)), + unpinPost: (postId) => dispatch(unpinPost(postId)), hidePost: (postId) => dispatch(hidePost(postId)), unhidePost: (postId) => dispatch(unhidePost(postId)), toggleModeratingComments: (postId) => dispatch(toggleModeratingComments(postId)), diff --git a/src/components/user-feed.jsx b/src/components/user-feed.jsx index 4d4cdbbf4..a8d277b53 100644 --- a/src/components/user-feed.jsx +++ b/src/components/user-feed.jsx @@ -86,7 +86,7 @@ class UserFeed extends Component { return ( - + ); } diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index 927e04cc1..930fcadd2 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -357,6 +357,22 @@ export function enableComments(postId) { }; } +export function pinPost(postId) { + return { + type: ActionTypes.PIN_POST, + apiRequest: Api.pinPost, + payload: { postId }, + }; +} + +export function unpinPost(postId) { + return { + type: ActionTypes.UNPIN_POST, + apiRequest: Api.unpinPost, + payload: { postId }, + }; +} + export function toggleEditingComment(commentId) { return { type: ActionTypes.TOGGLE_EDITING_COMMENT, diff --git a/src/redux/action-types.js b/src/redux/action-types.js index d48f13d00..3acfc5cca 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -37,6 +37,8 @@ export const SAVE_POST = 'SAVE_POST'; export const TOGGLE_MODERATING_COMMENTS = 'TOGGLE_MODERATING_COMMENTS'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const ENABLE_COMMENTS = 'ENABLE_COMMENTS'; +export const PIN_POST = 'PIN_POST'; +export const UNPIN_POST = 'UNPIN_POST'; export const TOGGLE_EDITING_COMMENT = 'TOGGLE_EDITING_COMMENT'; export const SAVE_EDITING_COMMENT = 'SAVE_EDITING_COMMENT'; export const LIKE_COMMENT = 'LIKE_COMMENT'; diff --git a/src/redux/reducers/posts.js b/src/redux/reducers/posts.js index 990dc4600..055483f04 100644 --- a/src/redux/reducers/posts.js +++ b/src/redux/reducers/posts.js @@ -11,6 +11,8 @@ import { DELETE_POST, DISABLE_COMMENTS, ENABLE_COMMENTS, + PIN_POST, + UNPIN_POST, GET_SINGLE_POST, HIDE_POST, LIKE_POST, @@ -430,6 +432,30 @@ export function posts(state = {}, action) { }, }; } + case response(PIN_POST): { + const post = state[action.request.postId]; + return post + ? { + ...state, + [post.id]: { + ...post, + isPinned: true, + }, + } + : state; + } + case response(UNPIN_POST): { + const post = state[action.request.postId]; + return post + ? { + ...state, + [post.id]: { + ...post, + isPinned: false, + }, + } + : state; + } case response(CREATE_POST): case response(GET_SINGLE_POST): { return updatePostData(state, action); diff --git a/src/services/api.js b/src/services/api.js index 41eca0763..5453e74fd 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -218,6 +218,15 @@ export function enableComments({ postId }) { return fetch(`${apiPrefix}/posts/${postId}/enableComments`, postRequestOptions()); } +// Pin/unpin a post to user's feed (server must support these endpoints) +export function pinPost({ postId }) { + return fetch(`${apiPrefix}/posts/${postId}/pin`, postRequestOptions()); +} + +export function unpinPost({ postId }) { + return fetch(`${apiPrefix}/posts/${postId}/unpin`, postRequestOptions()); +} + const encodeBody = (body) => _.map(body, (value, key) => `${key}=${encodeURIComponent(value)}`).join('&'); From ddd8b86b11cd14dd935be6f65c9bc56faf6d1d5a Mon Sep 17 00:00:00 2001 From: Roman Ivanov Date: Mon, 11 Aug 2025 10:55:28 +0300 Subject: [PATCH 2/6] feat: group pinning, ordering, tooltips, notifications; lint fixes --- src/components/feed.jsx | 24 +++++++--- src/components/notifications.jsx | 13 ++++++ src/components/post/post-more-menu.jsx | 47 ++++++++++++++----- src/components/post/post.jsx | 55 ++++++++++++++++++++-- src/components/select-utils.js | 4 +- src/redux/action-creators.js | 8 ++-- src/redux/reducers.js | 11 +++-- src/redux/reducers/posts.js | 63 ++++++++++++++++++-------- src/services/api.js | 8 ++-- 9 files changed, 179 insertions(+), 54 deletions(-) diff --git a/src/components/feed.jsx b/src/components/feed.jsx index f23f95c4a..8d7adc6c8 100644 --- a/src/components/feed.jsx +++ b/src/components/feed.jsx @@ -86,7 +86,7 @@ class Feed extends PureComponent { const postIsHidden = (post) => !!(post.isHidden || post.hiddenByCriteria); export default connect( - (state, ownProps) => { + (state) => { const { entries, recentlyHiddenEntries, isHiddenRevealed, feedError, feedRequestType } = state.feedViewState; @@ -106,15 +106,22 @@ export default connect( hiddenPosts = allPosts.filter((p) => postIsHidden(p)); } - // If we're on a user feed, lift pinned posts to the top while preserving - // the original order within pinned/non-pinned groups. - if (ownProps.isInUserFeed) { + // Local reorder for current feed: pinned posts (for this owner) first, then others. + const owner = state.feedViewState.timeline?.user || null; + if (owner) { const withIndex = visiblePosts.map((p, i) => ({ p, i })); withIndex.sort((a, b) => { - const ap = a.p.isPinned ? 1 : 0; - const bp = b.p.isPinned ? 1 : 0; + const ap = a.p.pinnedIn?.includes(owner) ? 1 : 0; + const bp = b.p.pinnedIn?.includes(owner) ? 1 : 0; if (ap !== bp) { - return bp - ap; + return bp - ap; // pinned first + } + if (ap === 1 && bp === 1) { + const ta = a.p.pinnedMeta?.[owner] || 0; + const tb = b.p.pinnedMeta?.[owner] || 0; + if (ta !== tb) { + return ta - tb; // last pinned goes last + } } return a.i - b.i; }); @@ -129,6 +136,8 @@ export default connect( visiblePosts, hiddenPosts, feedError, + managedGroups: state.managedGroups, + currentFeedOwnerId: state.feedViewState.timeline?.user || null, }; }, { toggleHiddenPosts }, @@ -159,6 +168,7 @@ function FeedEntry({ post, section, ...props }) { user={props.user} isInHomeFeed={props.isInHomeFeed} isInUserFeed={props.isInUserFeed} + currentFeedOwnerId={props.currentFeedOwnerId} showMoreComments={props.showMoreComments} showMoreLikes={props.showMoreLikes} toggleEditingPost={props.toggleEditingPost} diff --git a/src/components/notifications.jsx b/src/components/notifications.jsx index e62386636..d4b9afbbe 100644 --- a/src/components/notifications.jsx +++ b/src/components/notifications.jsx @@ -348,6 +348,19 @@ const notificationTemplates = { ), + post_pinned_in_group: (event) => ( + <> + pinned{' '} + {postLink(event, { fallback: 'your post' })} to + + ), + post_unpinned_in_group: (event) => ( + <> + unpinned{' '} + {postLink(event, { fallback: 'your post' })} from + + ), + blocked_in_group: (event) => ( <> r.type === 'user' && r.id === postCreatedBy?.id); + // Groups this viewer can administer among post recipients (via recipients' administrators) + const eligibleGroups = recipients.filter( + (r) => r.type === 'group' && (r.administrators || []).includes(user.id), + ); + const pinTargets = []; + if (isOwnPost && isInAuthorPosts) { + pinTargets.push({ id: user.id, label: 'profile' }); + } + for (const g of eligibleGroups) { + pinTargets.push({ id: g.id, label: `@${g.username}` }); + } + const deleteLines = useMemo(() => { const result = []; // Not owned post @@ -87,17 +103,26 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( ), ], [ - isOwnPost && ( -
- {isPinned ? ( - unpinPost(postId))}> - Unpin from profile - - ) : ( - pinPost(postId))}> - Pin to profile - - )} + pinTargets.length > 0 && ( +
+ {pinTargets.map((t) => { + const isPinnedHere = pinnedIn?.includes(t.id) || (t.id === user.id && isPinned); + return ( + unpinPost(postId, t.id)) + : doAndClose(() => pinPost(postId, t.id)) + } + > + + {isPinnedHere ? `Unpin from ${t.label}` : `Pin to ${t.label}`} + + + ); + })}
), isEditable && ( diff --git a/src/components/post/post.jsx b/src/components/post/post.jsx index c0ee09960..3244d5399 100644 --- a/src/components/post/post.jsx +++ b/src/components/post/post.jsx @@ -11,6 +11,7 @@ import { faGlobeAmericas, faAngleDoubleRight, faShare, + faThumbtack, } from '@fortawesome/free-solid-svg-icons'; import { pluralForm } from '../../utils'; @@ -27,7 +28,6 @@ import TimeDisplay from '../time-display'; import LinkPreview from '../link-preview/preview'; import ErrorBoundary from '../error-boundary'; import { Icon } from '../fontawesome-icons'; -import { faThumbtack } from '@fortawesome/free-solid-svg-icons'; import { UserPicture } from '../user-picture'; import { prepareAsyncFocus } from '../../utils/prepare-async-focus'; @@ -51,6 +51,7 @@ class Post extends Component { selectFeeds; hideLink = createRef(); textareaRef = createRef(); + containerRef = createRef(); state = { forceAbsTimestamps: false, @@ -149,6 +150,52 @@ class Post extends Component { this.props.setFinalHideLinkOffset(this.hideLink.current.getBoundingClientRect().top); } + componentDidMount() { + if (this.props.justCreated && this.props.currentFeedOwnerId) { + const el = this.containerRef.current; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + } + + renderPinIcon(props) { + const pinnedIds = props.pinnedIn || []; + const owner = this.props.currentFeedOwnerId; + const inOwner = owner ? pinnedIds.includes(owner) : false; + const anyPinned = pinnedIds.length > 0; + if (owner) { + if (!inOwner) { + return null; + } + } else if (!anyPinned) { + return null; + } + const names = []; + if (pinnedIds.includes(props.createdBy.id)) { + names.push(`@${props.createdBy.username}`); + } + for (const r of props.recipients || []) { + if (r.type === 'group' && pinnedIds.includes(r.id)) { + names.push(`@${r.username}`); + } + } + let title = 'Pinned'; + if (owner) { + title = owner === props.createdBy.id ? 'Pinned in profile' : 'Pinned in group'; + } else if (names.length > 0) { + title = `Pinned to ${names.join(', ')}`; + } + return ( + + ); + } + renderHideLink() { const { props } = this; return ( @@ -281,6 +328,7 @@ class Post extends Component { const moreLink = ( - {props.isPinned && ( - - )} + {this.renderPinIcon(props)} {props.commentsDisabled && ( @@ -415,6 +461,7 @@ class Post extends Component { data-author={props.createdBy.username} role={role} aria-label={postLabel} + ref={this.containerRef} > dispatch(likePost(postId, userId)), unlikePost: (postId, userId) => dispatch(unlikePost(postId, userId)), - pinPost: (postId) => dispatch(pinPost(postId)), - unpinPost: (postId) => dispatch(unpinPost(postId)), + pinPost: (postId, owner) => dispatch(pinPost(postId, owner)), + unpinPost: (postId, owner) => dispatch(unpinPost(postId, owner)), hidePost: (postId) => dispatch(hidePost(postId)), unhidePost: (postId) => dispatch(unhidePost(postId)), toggleModeratingComments: (postId) => dispatch(toggleModeratingComments(postId)), diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index 930fcadd2..10d5e7a8a 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -357,19 +357,19 @@ export function enableComments(postId) { }; } -export function pinPost(postId) { +export function pinPost(postId, owner) { return { type: ActionTypes.PIN_POST, apiRequest: Api.pinPost, - payload: { postId }, + payload: { postId, owner }, }; } -export function unpinPost(postId) { +export function unpinPost(postId, owner) { return { type: ActionTypes.UNPIN_POST, apiRequest: Api.unpinPost, - payload: { postId }, + payload: { postId, owner }, }; } diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 8c6f4563c..2cc83246e 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -358,6 +358,13 @@ export function postsViewState(state = {}, action) { }); } switch (action.type) { + case response(ActionTypes.CREATE_POST): { + const postId = action.payload.posts.id; + return { + ...state, + [postId]: { ...initPostViewState(action.payload.posts), justCreated: true }, + }; + } case response(ActionTypes.SHOW_MORE_LIKES_ASYNC): { const { id } = action.payload.posts; const omittedLikes = 0; @@ -612,10 +619,6 @@ export function postsViewState(state = {}, action) { }; } - case response(ActionTypes.CREATE_POST): { - const post = action.payload.posts; - return { ...state, [post.id]: { ...initPostViewState(post), justCreated: true } }; - } case ActionTypes.UNAUTHENTICATED: { return {}; } diff --git a/src/redux/reducers/posts.js b/src/redux/reducers/posts.js index 055483f04..072ebc7aa 100644 --- a/src/redux/reducers/posts.js +++ b/src/redux/reducers/posts.js @@ -434,27 +434,50 @@ export function posts(state = {}, action) { } case response(PIN_POST): { const post = state[action.request.postId]; - return post - ? { - ...state, - [post.id]: { - ...post, - isPinned: true, - }, - } - : state; + if (!post) { + return state; + } + const { owner } = action.request; + const pinnedIn = [...(post.pinnedIn || [])]; + if (owner && !pinnedIn.includes(owner)) { + pinnedIn.push(owner); + } + const isPinned = pinnedIn.includes(post.createdBy) || post.isPinned; + const pinnedMeta = { + ...(post.pinnedMeta || {}), + ...(owner ? { [owner]: Date.now() } : {}), + }; + return { + ...state, + [post.id]: { + ...post, + pinnedIn, + isPinned, + pinnedMeta, + }, + }; } case response(UNPIN_POST): { const post = state[action.request.postId]; - return post - ? { - ...state, - [post.id]: { - ...post, - isPinned: false, - }, - } - : state; + if (!post) { + return state; + } + const { owner } = action.request; + const pinnedIn = (post.pinnedIn || []).filter((id) => id !== owner); + const isPinned = pinnedIn.includes(post.createdBy) && post.isPinned; + const pinnedMeta = { ...(post.pinnedMeta || {}) }; + if (owner && pinnedMeta[owner]) { + delete pinnedMeta[owner]; + } + return { + ...state, + [post.id]: { + ...post, + pinnedIn, + isPinned, + pinnedMeta, + }, + }; } case response(CREATE_POST): case response(GET_SINGLE_POST): { @@ -498,6 +521,8 @@ export function posts(state = {}, action) { if (!post) { return state; } + const pinnedIn = action.post.pinnedIn ?? post.pinnedIn; + const isPinned = pinnedIn ? pinnedIn.includes(action.post.createdBy) : post.isPinned; return { ...state, [post.id]: { @@ -508,6 +533,8 @@ export function posts(state = {}, action) { postedTo: action.post.postedTo, backlinksCount: action.post.backlinksCount, notifyOfAllComments: action.post.notifyOfAllComments, + ...(pinnedIn ? { pinnedIn } : {}), + ...(isPinned !== undefined ? { isPinned } : {}), }, }; } diff --git a/src/services/api.js b/src/services/api.js index 5453e74fd..7e21fc037 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -219,12 +219,12 @@ export function enableComments({ postId }) { } // Pin/unpin a post to user's feed (server must support these endpoints) -export function pinPost({ postId }) { - return fetch(`${apiPrefix}/posts/${postId}/pin`, postRequestOptions()); +export function pinPost({ postId, owner }) { + return fetch(`${apiPrefix}/posts/${postId}/pin`, postRequestOptions('POST', { owner })); } -export function unpinPost({ postId }) { - return fetch(`${apiPrefix}/posts/${postId}/unpin`, postRequestOptions()); +export function unpinPost({ postId, owner }) { + return fetch(`${apiPrefix}/posts/${postId}/unpin`, postRequestOptions('POST', { owner })); } const encodeBody = (body) => From a614b0714dbbfa5046779df794797c1158416aef Mon Sep 17 00:00:00 2001 From: Roman Ivanov Date: Sat, 16 Aug 2025 16:55:04 +0300 Subject: [PATCH 3/6] several improvements --- src/components/post/post-more-menu.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/post/post-more-menu.jsx b/src/components/post/post-more-menu.jsx index 478e54acb..218532055 100644 --- a/src/components/post/post-more-menu.jsx +++ b/src/components/post/post-more-menu.jsx @@ -103,6 +103,13 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( ), ], [ + isEditable && ( +
+ + Edit + +
+ ), pinTargets.length > 0 && (
{pinTargets.map((t) => { @@ -125,13 +132,6 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( })}
), - isEditable && ( -
- - Edit - -
- ), isModeratable && (
From 6aae71ae79bfa392ce0a08e64187d4a645767ab6 Mon Sep 17 00:00:00 2001 From: Roman Ivanov Date: Sat, 16 Aug 2025 18:08:47 +0300 Subject: [PATCH 4/6] Removed isPinned, expanded pinnedIn --- src/components/feed.jsx | 22 +++++++++++++++++---- src/components/post/post-more-menu.jsx | 5 +++-- src/components/post/post.jsx | 13 ++++++++----- src/redux/reducers/posts.js | 27 ++++++++------------------ 4 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/components/feed.jsx b/src/components/feed.jsx index 8d7adc6c8..111ab4305 100644 --- a/src/components/feed.jsx +++ b/src/components/feed.jsx @@ -111,14 +111,28 @@ export default connect( if (owner) { const withIndex = visiblePosts.map((p, i) => ({ p, i })); withIndex.sort((a, b) => { - const ap = a.p.pinnedIn?.includes(owner) ? 1 : 0; - const bp = b.p.pinnedIn?.includes(owner) ? 1 : 0; + const ap = a.p.pinnedIn?.some((e) => + typeof e === 'string' ? e === owner : e?.ownerId === owner, + ) + ? 1 + : 0; + const bp = b.p.pinnedIn?.some((e) => + typeof e === 'string' ? e === owner : e?.ownerId === owner, + ) + ? 1 + : 0; if (ap !== bp) { return bp - ap; // pinned first } if (ap === 1 && bp === 1) { - const ta = a.p.pinnedMeta?.[owner] || 0; - const tb = b.p.pinnedMeta?.[owner] || 0; + const taStr = a.p.pinnedIn?.find((e) => + typeof e === 'string' ? e === owner : e?.ownerId === owner, + )?.pinnedAt; + const tbStr = b.p.pinnedIn?.find((e) => + typeof e === 'string' ? e === owner : e?.ownerId === owner, + )?.pinnedAt; + const ta = taStr ? Date.parse(taStr) : 0; + const tb = tbStr ? Date.parse(tbStr) : 0; if (ta !== tb) { return ta - tb; // last pinned goes last } diff --git a/src/components/post/post-more-menu.jsx b/src/components/post/post-more-menu.jsx index 218532055..5e83fd13a 100644 --- a/src/components/post/post-more-menu.jsx +++ b/src/components/post/post-more-menu.jsx @@ -29,7 +29,6 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( isDeletable = false, isModeratingComments = false, commentsDisabled = false, - isPinned = false, createdAt, updatedAt, createdBy: postCreatedBy, @@ -113,7 +112,9 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu( pinTargets.length > 0 && (
{pinTargets.map((t) => { - const isPinnedHere = pinnedIn?.includes(t.id) || (t.id === user.id && isPinned); + const isPinnedHere = pinnedIn?.some((e) => + typeof e === 'string' ? e === t.id : e?.ownerId === t.id, + ); return ( (typeof e === 'string' ? e : e?.ownerId)) + .filter(Boolean); const owner = this.props.currentFeedOwnerId; - const inOwner = owner ? pinnedIds.includes(owner) : false; - const anyPinned = pinnedIds.length > 0; + const inOwner = owner ? asOwnerIds.includes(owner) : false; + const anyPinned = asOwnerIds.length > 0; if (owner) { if (!inOwner) { return null; @@ -172,11 +175,11 @@ class Post extends Component { return null; } const names = []; - if (pinnedIds.includes(props.createdBy.id)) { + if (asOwnerIds.includes(props.createdBy.id)) { names.push(`@${props.createdBy.username}`); } for (const r of props.recipients || []) { - if (r.type === 'group' && pinnedIds.includes(r.id)) { + if (r.type === 'group' && asOwnerIds.includes(r.id)) { names.push(`@${r.username}`); } } diff --git a/src/redux/reducers/posts.js b/src/redux/reducers/posts.js index 072ebc7aa..970d619ec 100644 --- a/src/redux/reducers/posts.js +++ b/src/redux/reducers/posts.js @@ -439,21 +439,17 @@ export function posts(state = {}, action) { } const { owner } = action.request; const pinnedIn = [...(post.pinnedIn || [])]; - if (owner && !pinnedIn.includes(owner)) { - pinnedIn.push(owner); + const hasOwner = owner + ? pinnedIn.some((e) => (typeof e === 'string' ? e === owner : e?.ownerId === owner)) + : false; + if (owner && !hasOwner) { + pinnedIn.push({ ownerId: owner, pinnedAt: new Date().toISOString(), pinnedBy: null }); } - const isPinned = pinnedIn.includes(post.createdBy) || post.isPinned; - const pinnedMeta = { - ...(post.pinnedMeta || {}), - ...(owner ? { [owner]: Date.now() } : {}), - }; return { ...state, [post.id]: { ...post, pinnedIn, - isPinned, - pinnedMeta, }, }; } @@ -463,19 +459,14 @@ export function posts(state = {}, action) { return state; } const { owner } = action.request; - const pinnedIn = (post.pinnedIn || []).filter((id) => id !== owner); - const isPinned = pinnedIn.includes(post.createdBy) && post.isPinned; - const pinnedMeta = { ...(post.pinnedMeta || {}) }; - if (owner && pinnedMeta[owner]) { - delete pinnedMeta[owner]; - } + const pinnedIn = (post.pinnedIn || []).filter((e) => + typeof e === 'string' ? e !== owner : e?.ownerId !== owner, + ); return { ...state, [post.id]: { ...post, pinnedIn, - isPinned, - pinnedMeta, }, }; } @@ -522,7 +513,6 @@ export function posts(state = {}, action) { return state; } const pinnedIn = action.post.pinnedIn ?? post.pinnedIn; - const isPinned = pinnedIn ? pinnedIn.includes(action.post.createdBy) : post.isPinned; return { ...state, [post.id]: { @@ -534,7 +524,6 @@ export function posts(state = {}, action) { backlinksCount: action.post.backlinksCount, notifyOfAllComments: action.post.notifyOfAllComments, ...(pinnedIn ? { pinnedIn } : {}), - ...(isPinned !== undefined ? { isPinned } : {}), }, }; } From bc9942afcf85576118876a4a3010ace3a486dade Mon Sep 17 00:00:00 2001 From: Roman Ivanov Date: Sat, 16 Aug 2025 18:34:42 +0300 Subject: [PATCH 5/6] Some fixes --- src/components/feed.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/feed.jsx b/src/components/feed.jsx index 111ab4305..d3840223f 100644 --- a/src/components/feed.jsx +++ b/src/components/feed.jsx @@ -108,7 +108,9 @@ export default connect( // Local reorder for current feed: pinned posts (for this owner) first, then others. const owner = state.feedViewState.timeline?.user || null; - if (owner) { + const timelineName = state.feedViewState.timeline?.name || null; + // Only reorder in user/group Posts timelines + if (owner && timelineName === 'Posts') { const withIndex = visiblePosts.map((p, i) => ({ p, i })); withIndex.sort((a, b) => { const ap = a.p.pinnedIn?.some((e) => From 71023ab01f2f9b1fc5c0c2386ef0f53c81a938ce Mon Sep 17 00:00:00 2001 From: Roman Ivanov Date: Thu, 21 Aug 2025 11:41:25 +0300 Subject: [PATCH 6/6] Pins: client side sorting moved to redux + fixes --- src/components/feed.jsx | 38 +--------- src/components/notifications.jsx | 60 +++++++++++++--- src/redux/action-types.js | 1 + src/redux/configure-store.js | 3 + src/redux/middlewares.js | 120 +++++++++++++++++++++++++++++++ src/redux/reducers.js | 3 + 6 files changed, 178 insertions(+), 47 deletions(-) diff --git a/src/components/feed.jsx b/src/components/feed.jsx index d3840223f..db6e20b19 100644 --- a/src/components/feed.jsx +++ b/src/components/feed.jsx @@ -106,43 +106,7 @@ export default connect( hiddenPosts = allPosts.filter((p) => postIsHidden(p)); } - // Local reorder for current feed: pinned posts (for this owner) first, then others. - const owner = state.feedViewState.timeline?.user || null; - const timelineName = state.feedViewState.timeline?.name || null; - // Only reorder in user/group Posts timelines - if (owner && timelineName === 'Posts') { - const withIndex = visiblePosts.map((p, i) => ({ p, i })); - withIndex.sort((a, b) => { - const ap = a.p.pinnedIn?.some((e) => - typeof e === 'string' ? e === owner : e?.ownerId === owner, - ) - ? 1 - : 0; - const bp = b.p.pinnedIn?.some((e) => - typeof e === 'string' ? e === owner : e?.ownerId === owner, - ) - ? 1 - : 0; - if (ap !== bp) { - return bp - ap; // pinned first - } - if (ap === 1 && bp === 1) { - const taStr = a.p.pinnedIn?.find((e) => - typeof e === 'string' ? e === owner : e?.ownerId === owner, - )?.pinnedAt; - const tbStr = b.p.pinnedIn?.find((e) => - typeof e === 'string' ? e === owner : e?.ownerId === owner, - )?.pinnedAt; - const ta = taStr ? Date.parse(taStr) : 0; - const tb = tbStr ? Date.parse(tbStr) : 0; - if (ta !== tb) { - return ta - tb; // last pinned goes last - } - } - return a.i - b.i; - }); - visiblePosts = withIndex.map(({ p }) => p); - } + // Order is managed by Redux (reorderPinnedMiddleware) for Posts timelines return { loading: state.routeLoadingState, diff --git a/src/components/notifications.jsx b/src/components/notifications.jsx index d4b9afbbe..36890abed 100644 --- a/src/components/notifications.jsx +++ b/src/components/notifications.jsx @@ -19,22 +19,31 @@ import { Icon } from './fontawesome-icons'; import { useBool } from './hooks/bool'; import { Expandable } from './expandable'; -const getAuthorName = ({ postAuthor, createdUser, group }) => { - if (group && group.username) { +const getAuthorNameOrNull = ({ postAuthor, createdUser, group, receiver }) => { + if (group?.username) { return group.username; } - if (postAuthor && postAuthor.username) { + if (postAuthor?.username) { return postAuthor.username; } - return createdUser.username; + if (createdUser?.username) { + return createdUser.username; + } + if (receiver?.username) { + return receiver.username; + } + return null; }; -const generatePostUrl = ({ post_id, shortPostId, ...event }) => - `/${getAuthorName(event)}/${shortPostId ?? post_id}`; -const generateCommentUrl = ({ post_id, comment_id, shortPostId, shortCommentId, ...event }) => - `/${getAuthorName(event)}/${shortPostId ?? post_id}#${ - shortCommentId ? shortCommentId : `comment-${comment_id}` - }`; +const generatePostUrl = ({ post_id, shortPostId, ...event }) => { + const author = getAuthorNameOrNull(event); + return author ? `/${author}/${shortPostId ?? post_id}` : `/post/${post_id}`; +}; +const generateCommentUrl = ({ post_id, comment_id, shortPostId, shortCommentId, ...event }) => { + const author = getAuthorNameOrNull(event); + const anchor = shortCommentId ? shortCommentId : `comment-${comment_id}`; + return author ? `/${author}/${shortPostId ?? post_id}#${anchor}` : `/post/${post_id}#${anchor}`; +}; const postLink = ( event, { isDirect = false, fallback = isDirect ? 'deleted direct message' : 'deleted post' } = {}, @@ -361,6 +370,35 @@ const notificationTemplates = { ), + post_pinned_in_profile: (event) => { + const isSelf = event.receiver?.id && event.receiver.id === event.created_user_id; + const postEl = isSelf ? ( + your post + ) : ( + postLink(event, { fallback: 'your post' }) + ); + return ( + <> + {isSelf ? 'You' : }{' '} + pinned {postEl} to profile + + ); + }, + post_unpinned_in_profile: (event) => { + const isSelf = event.receiver?.id && event.receiver.id === event.created_user_id; + const postEl = isSelf ? ( + your post + ) : ( + postLink(event, { fallback: 'your post' }) + ); + return ( + <> + {isSelf ? 'You' : }{' '} + unpinned {postEl} from profile + + ); + }, + blocked_in_group: (event) => ( <> false; diff --git a/src/redux/action-types.js b/src/redux/action-types.js index 3acfc5cca..31d2797ec 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -152,6 +152,7 @@ export const CREATE_HOME_FEED = 'CREATE_HOME_FEED'; export const UPDATE_HOME_FEED = 'UPDATE_HOME_FEED'; export const DELETE_HOME_FEED = 'DELETE_HOME_FEED'; export const REORDER_HOME_FEEDS = 'REORDER_HOME_FEEDS'; +export const FEED_REORDER_ENTRIES = 'FEED_REORDER_ENTRIES'; export const GET_ALL_SUBSCRIPTIONS = 'GET_ALL_SUBSCRIPTIONS'; export const DEACTIVATE_USER = 'DEACTIVATE_USER'; export const ACTIVATE_USER = 'ACTIVATE_USER'; diff --git a/src/redux/configure-store.js b/src/redux/configure-store.js index 9ae96cab3..3508d9a45 100644 --- a/src/redux/configure-store.js +++ b/src/redux/configure-store.js @@ -29,6 +29,7 @@ import { resetPasswordCompleteMiddleware, abortableUploadMiddleware, undoMiddleware, + reorderPinnedMiddleware, } from './middlewares'; import * as reducers from './reducers'; @@ -63,6 +64,8 @@ const middleware = [ reloadFeedMiddleware, draftsMiddleware, undoMiddleware, + // Place near the end to see updated state and responses + reorderPinnedMiddleware, ]; const enhancers = [applyMiddleware(...middleware)]; diff --git a/src/redux/middlewares.js b/src/redux/middlewares.js index 643e061ab..71ac672fe 100644 --- a/src/redux/middlewares.js +++ b/src/redux/middlewares.js @@ -617,6 +617,126 @@ export const markNotificationsAsReadMiddleware = (store) => (next) => (action) = next(action); }; +// Reorder current feed entries when a post gets (un)pinned in the visible Posts timeline +export const reorderPinnedMiddleware = (store) => (next) => (action) => { + const res = next(action); + + const typesToHandle = new Set([ + ActionTypes.PIN_POST, // base action from UI + ActionTypes.UNPIN_POST, + request(ActionTypes.PIN_POST), + request(ActionTypes.UNPIN_POST), + response(ActionTypes.PIN_POST), + response(ActionTypes.UNPIN_POST), + ActionTypes.REALTIME_POST_UPDATE, + ]); + if (!typesToHandle.has(action.type)) { + return res; + } + + const state = store.getState(); + const { timeline, entries } = state.feedViewState; + if (!timeline || timeline.name !== 'Posts' || !timeline.user) { + return res; + } + + const ownerId = timeline.user; + if (!entries || entries.length === 0) { + return res; + } + + // Build keyed list with possible optimistic tweak for PIN/UNPIN response + const withKeys = entries.map((id, idx) => { + const post = state.posts[id]; + const pinRec = (post?.pinnedIn || []).find((e) => + typeof e === 'string' ? e === ownerId : e?.ownerId === ownerId, + ); + let pinned = Boolean(pinRec); + let pinnedAt = pinned ? Date.parse(pinRec.pinnedAt) || 0 : 0; + + const isPinReq = + action.type === ActionTypes.PIN_POST || action.type === request(ActionTypes.PIN_POST); + const isUnpinReq = + action.type === ActionTypes.UNPIN_POST || action.type === request(ActionTypes.UNPIN_POST); + const isPinResp = action.type === response(ActionTypes.PIN_POST); + const isUnpinResp = action.type === response(ActionTypes.UNPIN_POST); + const req = isPinResp || isUnpinResp ? action.request : action.payload; + + if ((isPinResp || isPinReq) && req?.postId === id) { + // Ensure immediate reorder even if pinnedIn not yet updated by reducers + const targetOwner = req.owner || ownerId; + if (targetOwner === ownerId) { + pinned = true; + pinnedAt = pinnedAt || Date.now(); + } + } + if ((isUnpinResp || isUnpinReq) && req?.postId === id) { + const targetOwner = req.owner || ownerId; + if (targetOwner === ownerId) { + pinned = false; + pinnedAt = 0; + } + } + + return { id, idx, pinned, pinnedAt }; + }); + + const sorted = [...withKeys].sort((a, b) => { + if (a.pinned !== b.pinned) { + return a.pinned ? -1 : 1; // pinned first + } + if (a.pinned && b.pinned && a.pinnedAt !== b.pinnedAt) { + return a.pinnedAt - b.pinnedAt; // older pin first + } + return a.idx - b.idx; // stable + }); + + const newEntries = sorted.map((x) => x.id); + const changed = + newEntries.length !== entries.length || newEntries.some((v, i) => v !== entries[i]); + if (changed) { + store.dispatch({ type: ActionTypes.FEED_REORDER_ENTRIES, payload: { entries: newEntries } }); + } else { + // Fallback: deterministic move within current feed without relying on pinnedIn + const isPinAny = + action.type === ActionTypes.PIN_POST || + action.type === request(ActionTypes.PIN_POST) || + action.type === response(ActionTypes.PIN_POST); + const isUnpinAny = + action.type === ActionTypes.UNPIN_POST || + action.type === request(ActionTypes.UNPIN_POST) || + action.type === response(ActionTypes.UNPIN_POST); + if (isPinAny || isUnpinAny) { + const isResp = + action.type === response(ActionTypes.PIN_POST) || + action.type === response(ActionTypes.UNPIN_POST); + const req = isResp ? action.request : action.payload; + const targetId = req?.postId; + if (targetId && entries.includes(targetId)) { + const curr = entries.filter((e) => e !== targetId); + const isPinned = (pid) => { + const post = state.posts[pid]; + const rec = (post?.pinnedIn || []).find((e) => + typeof e === 'string' ? e === ownerId : e?.ownerId === ownerId, + ); + return Boolean(rec); + }; + const boundary = curr.findIndex((pid) => !isPinned(pid)); + const insertAt = boundary < 0 ? curr.length : boundary; + const newOrder = [...curr.slice(0, insertAt), targetId, ...curr.slice(insertAt)]; + if (newOrder.some((v, i) => v !== entries[i])) { + store.dispatch({ + type: ActionTypes.FEED_REORDER_ENTRIES, + payload: { entries: newOrder }, + }); + } + } + } + } + + return res; +}; + const isFirstPage = (state) => !state.routing.locationBeforeTransitions.query.offset; const isMemories = (state) => state.routing.locationBeforeTransitions.pathname.includes('memories'); diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 2cc83246e..50bc431dd 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -208,6 +208,9 @@ export function feedViewState(state = initFeed, action) { } switch (action.type) { + case ActionTypes.FEED_REORDER_ENTRIES: { + return { ...state, entries: action.payload.entries }; + } case ActionTypes.UNAUTHENTICATED: { return initFeed; }