diff --git a/src/components/feed.jsx b/src/components/feed.jsx index 0776495cc..db6e20b19 100644 --- a/src/components/feed.jsx +++ b/src/components/feed.jsx @@ -106,6 +106,8 @@ export default connect( hiddenPosts = allPosts.filter((p) => postIsHidden(p)); } + // Order is managed by Redux (reorderPinnedMiddleware) for Posts timelines + return { loading: state.routeLoadingState, emptyFeed: entries.length === 0, @@ -114,6 +116,8 @@ export default connect( visiblePosts, hiddenPosts, feedError, + managedGroups: state.managedGroups, + currentFeedOwnerId: state.feedViewState.timeline?.user || null, }; }, { toggleHiddenPosts }, @@ -144,12 +148,15 @@ 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} 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/notifications.jsx b/src/components/notifications.jsx index e62386636..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' } = {}, @@ -348,6 +357,48 @@ 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 + + ), + + 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/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('&');