Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions src/components/feed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -114,6 +116,8 @@ export default connect(
visiblePosts,
hiddenPosts,
feedError,
managedGroups: state.managedGroups,
currentFeedOwnerId: state.feedViewState.timeline?.user || null,
};
},
{ toggleHiddenPosts },
Expand Down Expand Up @@ -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}
Expand Down
73 changes: 63 additions & 10 deletions src/components/notifications.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' } = {},
Expand Down Expand Up @@ -348,6 +357,48 @@ const notificationTemplates = {
</>
),

post_pinned_in_group: (event) => (
<>
<UserLink atStart user={event.createdUser} /> pinned{' '}
{postLink(event, { fallback: 'your post' })} to <UserLink user={event.group} />
</>
),
post_unpinned_in_group: (event) => (
<>
<UserLink atStart user={event.createdUser} /> unpinned{' '}
{postLink(event, { fallback: 'your post' })} from <UserLink user={event.group} />
</>
),

post_pinned_in_profile: (event) => {
const isSelf = event.receiver?.id && event.receiver.id === event.created_user_id;
const postEl = isSelf ? (
<Link to={generatePostUrl(event)}>your post</Link>
) : (
postLink(event, { fallback: 'your post' })
);
return (
<>
{isSelf ? 'You' : <UserLink atStart user={event.createdUser} recipient={event.receiver} />}{' '}
pinned {postEl} to profile
</>
);
},
post_unpinned_in_profile: (event) => {
const isSelf = event.receiver?.id && event.receiver.id === event.created_user_id;
const postEl = isSelf ? (
<Link to={generatePostUrl(event)}>your post</Link>
) : (
postLink(event, { fallback: 'your post' })
);
return (
<>
{isSelf ? 'You' : <UserLink atStart user={event.createdUser} recipient={event.receiver} />}{' '}
unpinned {postEl} from profile
</>
);
},

blocked_in_group: (event) => (
<>
<UserLink
Expand Down Expand Up @@ -413,6 +464,8 @@ const notificationClasses = {
comment_moderated_by_another_admin: 'group',
post_moderated: 'group',
post_moderated_by_another_admin: 'group',
post_pinned_in_group: 'group',
post_unpinned_in_group: 'group',
};

const nop = () => false;
Expand Down
2 changes: 2 additions & 0 deletions src/components/post/post-more-link.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
44 changes: 43 additions & 1 deletion src/components/post/post-more-menu.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -91,6 +109,30 @@ export const PostMoreMenu = forwardRef(function PostMoreMenu(
</ButtonLink>
</div>
),
pinTargets.length > 0 && (
<div className={styles.item} key="pin-posts">
{pinTargets.map((t) => {
const isPinnedHere = pinnedIn?.some((e) =>
typeof e === 'string' ? e === t.id : e?.ownerId === t.id,
);
return (
<ButtonLink
key={`pin-${t.id}`}
className={styles.link}
onClick={
isPinnedHere
? doAndClose(() => unpinPost(postId, t.id))
: doAndClose(() => pinPost(postId, t.id))
}
>
<Iconic icon={faThumbtack}>
{isPinnedHere ? `Unpin from ${t.label}` : `Pin to ${t.label}`}
</Iconic>
</ButtonLink>
);
})}
</div>
),
isModeratable && (
<div className={styles.item} key="moderate-comments">
<ButtonLink className={styles.link} onClick={doAndClose(toggleModeratingComments)}>
Expand Down
56 changes: 56 additions & 0 deletions src/components/post/post.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
faGlobeAmericas,
faAngleDoubleRight,
faShare,
faThumbtack,
} from '@fortawesome/free-solid-svg-icons';

import { pluralForm } from '../../utils';
Expand Down Expand Up @@ -50,6 +51,7 @@
selectFeeds;
hideLink = createRef();
textareaRef = createRef();
containerRef = createRef();

state = {
forceAbsTimestamps: false,
Expand Down Expand Up @@ -148,6 +150,55 @@
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 (
<Icon
icon={faThumbtack}
className="post-pinned-icon"
title={title}
style={{ marginLeft: '0.5em' }}
/>
);
}

renderHideLink() {
const { props } = this;
return (
Expand Down Expand Up @@ -228,7 +279,7 @@
return { role, postLabel };
};

renderPostActions() {

Check warning on line 282 in src/components/post/post.jsx

View workflow job for this annotation

GitHub Actions / build (20)

Method 'renderPostActions' has a complexity of 21. Maximum allowed is 20

Check warning on line 282 in src/components/post/post.jsx

View workflow job for this annotation

GitHub Actions / build (22)

Method 'renderPostActions' has a complexity of 21. Maximum allowed is 20
const { props } = this;

const canonicalPostURI = canonicalURI(props);
Expand Down Expand Up @@ -280,12 +331,15 @@
const moreLink = (
<PostMoreLink
user={props.user}
managedGroups={this.props.managedGroups}
post={props}
toggleEditingPost={this.toggleEditingPost}
toggleModeratingComments={this.toggleModeratingComments}
disableComments={this.disableComments}
enableComments={this.enableComments}
deletePost={this.handleDeletePost}
pinPost={this.props.pinPost}
unpinPost={this.props.unpinPost}
toggleSave={this.toggleSave}
handleMentionAuthor={this.handleMentionAuthorClick}
/>
Expand Down Expand Up @@ -337,6 +391,7 @@
absolute={this.state.forceAbsTimestamps || null}
/>
</Link>
{this.renderPinIcon(props)}
</span>
{props.commentsDisabled && (
<span className="post-footer-item">
Expand Down Expand Up @@ -409,6 +464,7 @@
data-author={props.createdBy.username}
role={role}
aria-label={postLabel}
ref={this.containerRef}
>
<ErrorBoundary>
<Expandable
Expand Down
4 changes: 4 additions & 0 deletions src/components/select-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
cancelEditingPost,
saveEditingPost,
deletePost,
pinPost,
unpinPost,

// Comment actions
toggleCommenting,
Expand Down Expand Up @@ -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, 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)),
Expand Down
2 changes: 1 addition & 1 deletion src/components/user-feed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class UserFeed extends Component {

return (
<PaginatedView {...this.props}>
<Feed {...this.props} emptyFeedMessage={emptyFeedMessage} />
<Feed {...this.props} isInUserFeed emptyFeedMessage={emptyFeedMessage} />
</PaginatedView>
);
}
Expand Down
16 changes: 16 additions & 0 deletions src/redux/action-creators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/redux/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
Loading