false;
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..5e83fd13a 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';
@@ -33,12 +33,16 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu(
updatedAt,
createdBy: postCreatedBy,
isDirect = false,
+ recipients = [],
+ pinnedIn = [],
},
toggleEditingPost,
toggleModeratingComments,
enableComments,
disableComments,
deletePost,
+ pinPost,
+ unpinPost,
doAndClose,
doAndForceClose,
permalink,
@@ -59,6 +63,20 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu(
const amIAuthenticated = !!user.id;
const isOwnPost = amIAuthenticated && postCreatedBy?.id === user.id;
+ // Can pin to author's profile only if the post is actually in author's Posts feed
+ const isInAuthorPosts = recipients.some((r) => 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
@@ -91,6 +109,30 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu(
),
+ pinTargets.length > 0 && (
+
+ {pinTargets.map((t) => {
+ const isPinnedHere = pinnedIn?.some((e) =>
+ typeof e === 'string' ? e === t.id : e?.ownerId === t.id,
+ );
+ return (
+ unpinPost(postId, t.id))
+ : doAndClose(() => pinPost(postId, t.id))
+ }
+ >
+
+ {isPinnedHere ? `Unpin from ${t.label}` : `Pin to ${t.label}`}
+
+
+ );
+ })}
+
+ ),
isModeratable && (
diff --git a/src/components/post/post.jsx b/src/components/post/post.jsx
index 8bdaaf971..aebd2c8f9 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';
@@ -50,6 +51,7 @@ class Post extends Component {
selectFeeds;
hideLink = createRef();
textareaRef = createRef();
+ containerRef = createRef();
state = {
forceAbsTimestamps: false,
@@ -148,6 +150,55 @@ 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 pinEntries = props.pinnedIn || [];
+ const asOwnerIds = pinEntries
+ .map((e) => (typeof e === 'string' ? e : e?.ownerId))
+ .filter(Boolean);
+ const owner = this.props.currentFeedOwnerId;
+ const inOwner = owner ? asOwnerIds.includes(owner) : false;
+ const anyPinned = asOwnerIds.length > 0;
+ if (owner) {
+ if (!inOwner) {
+ return null;
+ }
+ } else if (!anyPinned) {
+ return null;
+ }
+ const names = [];
+ if (asOwnerIds.includes(props.createdBy.id)) {
+ names.push(`@${props.createdBy.username}`);
+ }
+ for (const r of props.recipients || []) {
+ if (r.type === 'group' && asOwnerIds.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 (
@@ -280,12 +331,15 @@ class Post extends Component {
const moreLink = (
@@ -337,6 +391,7 @@ class Post extends Component {
absolute={this.state.forceAbsTimestamps || null}
/>
+ {this.renderPinIcon(props)}
{props.commentsDisabled && (
@@ -409,6 +464,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, 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/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..10d5e7a8a 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, owner) {
+ return {
+ type: ActionTypes.PIN_POST,
+ apiRequest: Api.pinPost,
+ payload: { postId, owner },
+ };
+}
+
+export function unpinPost(postId, owner) {
+ return {
+ type: ActionTypes.UNPIN_POST,
+ apiRequest: Api.unpinPost,
+ payload: { postId, owner },
+ };
+}
+
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..31d2797ec 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';
@@ -150,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 8c6f4563c..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;
}
@@ -358,6 +361,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 +622,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 990dc4600..970d619ec 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,44 @@ export function posts(state = {}, action) {
},
};
}
+ case response(PIN_POST): {
+ const post = state[action.request.postId];
+ if (!post) {
+ return state;
+ }
+ const { owner } = action.request;
+ const pinnedIn = [...(post.pinnedIn || [])];
+ 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 });
+ }
+ return {
+ ...state,
+ [post.id]: {
+ ...post,
+ pinnedIn,
+ },
+ };
+ }
+ case response(UNPIN_POST): {
+ const post = state[action.request.postId];
+ if (!post) {
+ return state;
+ }
+ const { owner } = action.request;
+ const pinnedIn = (post.pinnedIn || []).filter((e) =>
+ typeof e === 'string' ? e !== owner : e?.ownerId !== owner,
+ );
+ return {
+ ...state,
+ [post.id]: {
+ ...post,
+ pinnedIn,
+ },
+ };
+ }
case response(CREATE_POST):
case response(GET_SINGLE_POST): {
return updatePostData(state, action);
@@ -472,6 +512,7 @@ export function posts(state = {}, action) {
if (!post) {
return state;
}
+ const pinnedIn = action.post.pinnedIn ?? post.pinnedIn;
return {
...state,
[post.id]: {
@@ -482,6 +523,7 @@ export function posts(state = {}, action) {
postedTo: action.post.postedTo,
backlinksCount: action.post.backlinksCount,
notifyOfAllComments: action.post.notifyOfAllComments,
+ ...(pinnedIn ? { pinnedIn } : {}),
},
};
}
diff --git a/src/services/api.js b/src/services/api.js
index 41eca0763..7e21fc037 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, owner }) {
+ return fetch(`${apiPrefix}/posts/${postId}/pin`, postRequestOptions('POST', { owner }));
+}
+
+export function unpinPost({ postId, owner }) {
+ return fetch(`${apiPrefix}/posts/${postId}/unpin`, postRequestOptions('POST', { owner }));
+}
+
const encodeBody = (body) =>
_.map(body, (value, key) => `${key}=${encodeURIComponent(value)}`).join('&');