diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index b2517301ff..77bccb41ff 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -7,10 +7,11 @@ import api from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; +import { expandGroupFeaturedTimeline } from './timelines'; import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities'; +import type { APIEntity, Group, Status as StatusEntity } from 'soapbox/types/entities'; const REBLOG_REQUEST = 'REBLOG_REQUEST'; const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -511,6 +512,20 @@ const pin = (status: StatusEntity) => }); }; +const pinToGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/pin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + +const unpinFromGroup = (status: StatusEntity, group: Group) => + (dispatch: AppDispatch, getState: () => RootState) => { + return api(getState) + .post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/unpin`) + .then(() => dispatch(expandGroupFeaturedTimeline(group.id))); + }; + const pinRequest = (status: StatusEntity) => ({ type: PIN_REQUEST, status, @@ -715,6 +730,8 @@ export { unpinSuccess, unpinFail, togglePin, + pinToGroup, + unpinFromGroup, remoteInteraction, remoteInteractionRequest, remoteInteractionSuccess, diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 902b99f702..1df112dae3 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -248,6 +248,9 @@ const expandListTimeline = (id: string, { maxId }: Record = {}, don const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); +const expandGroupFeaturedTimeline = (id: string) => + expandTimeline(`group:${id}:pinned`, `/api/v1/timelines/group/${id}`, { pinned: true }); + const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done); @@ -353,6 +356,7 @@ export { expandAccountMediaTimeline, expandListTimeline, expandGroupTimeline, + expandGroupFeaturedTimeline, expandGroupTimelineFromTag, expandGroupMediaTimeline, expandHashtagTimeline, diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 0d7382d91f..3e6cae67a8 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -7,7 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts'; import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { editEvent } from 'soapbox/actions/events'; -import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; +import { pinToGroup, toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog, unpinFromGroup } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; @@ -32,64 +32,67 @@ import type { Menu } from 'soapbox/components/dropdown-menu'; import type { Account, Group, Status } from 'soapbox/types/entities'; const messages = defineMessages({ - delete: { id: 'status.delete', defaultMessage: 'Delete' }, - redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, - edit: { id: 'status.edit', defaultMessage: 'Edit' }, - direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, - chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, - mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, - mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, - block: { id: 'account.block', defaultMessage: 'Block @{name}' }, - reply: { id: 'status.reply', defaultMessage: 'Reply' }, - share: { id: 'status.share', defaultMessage: 'Share' }, - more: { id: 'status.more', defaultMessage: 'More' }, - replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, - reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, - favourite: { id: 'status.favourite', defaultMessage: 'Like' }, - disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, - open: { id: 'status.open', defaultMessage: 'Expand this post' }, - bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, - unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, - report: { id: 'status.report', defaultMessage: 'Report @{name}' }, - muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, - unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, - pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, - unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, - embed: { id: 'status.embed', defaultMessage: 'Embed' }, adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, + chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, - group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, - group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, - external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, - deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, - deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, - markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, - markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, - reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, - reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, - reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, - reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, - reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, - reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, - quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + delete: { id: 'status.delete', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, - redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, - replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, - replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, + deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, + deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, + external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, + favourite: { id: 'status.favourite', defaultMessage: 'Like' }, groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' }, - deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' }, + group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, + group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, + markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, + markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + more: { id: 'status.more', defaultMessage: 'More' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + open: { id: 'status.open', defaultMessage: 'Expand this post' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' }, + pinToGroupSuccess: { id: 'status.pin_to_group.success', defaultMessage: 'Pinned to Group!' }, + unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' }, + quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, + reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, + reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, + reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, + reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, + reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, + reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, + replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, }); interface IStatusActionBar { @@ -232,6 +235,18 @@ const StatusActionBar: React.FC = ({ dispatch(togglePin(status)); }; + const handleGroupPinClick: React.EventHandler = () => { + const group = status.group as Group; + + if (status.pinned) { + dispatch(unpinFromGroup(status, group)); + } else { + dispatch(pinToGroup(status, group)) + .then(() => toast.success(intl.formatMessage(messages.pinToGroupSuccess))) + .catch(() => null); + } + }; + const handleMentionClick: React.EventHandler = (e) => { dispatch(mentionCompose(status.account as Account)); }; @@ -358,6 +373,19 @@ const StatusActionBar: React.FC = ({ return menu; } + const isGroupStatus = typeof status.group === 'object'; + if (isGroupStatus && !!status.group) { + const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; + + if (isGroupOwner) { + menu.push({ + text: intl.formatMessage(status.pinned ? messages.unpinFromGroup : messages.pinToGroup), + action: handleGroupPinClick, + icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), + }); + } + } + if (features.bookmarks) { menu.push({ text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), @@ -460,7 +488,6 @@ const StatusActionBar: React.FC = ({ }); } - const isGroupStatus = typeof status.group === 'object'; if (isGroupStatus && !!status.group) { const group = status.group as Group; const account = status.account as Account; diff --git a/app/soapbox/features/group/group-timeline.tsx b/app/soapbox/features/group/group-timeline.tsx index a920a862c7..3c736cd78e 100644 --- a/app/soapbox/features/group/group-timeline.tsx +++ b/app/soapbox/features/group/group-timeline.tsx @@ -5,11 +5,12 @@ import { Link } from 'react-router-dom'; import { groupCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose'; import { connectGroupStream } from 'soapbox/actions/streaming'; -import { expandGroupTimeline } from 'soapbox/actions/timelines'; +import { expandGroupFeaturedTimeline, expandGroupTimeline } from 'soapbox/actions/timelines'; import { useGroup } from 'soapbox/api/hooks'; import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui'; import ComposeForm from 'soapbox/features/compose/components/compose-form'; import { useAppDispatch, useAppSelector, useDraggedFiles, useOwnAccount } from 'soapbox/hooks'; +import { makeGetStatusIds } from 'soapbox/selectors'; import Timeline from '../ui/components/timeline'; @@ -19,6 +20,8 @@ interface IGroupTimeline { params: RouteParams } +const getStatusIds = makeGetStatusIds(); + const GroupTimeline: React.FC = (props) => { const intl = useIntl(); const account = useOwnAccount(); @@ -32,6 +35,7 @@ const GroupTimeline: React.FC = (props) => { const composeId = `group:${groupId}`; const canComposeGroupStatus = !!account && group?.relationship?.member; const groupTimelineVisible = useAppSelector((state) => !!state.compose.get(composeId)?.group_timeline_visible); + const featuredStatusIds = useAppSelector((state) => getStatusIds(state, { type: `group:${group?.id}:pinned` })); const { isDragging, isDraggedOver } = useDraggedFiles(composer, (files) => { dispatch(uploadCompose(composeId, files, intl)); @@ -47,6 +51,7 @@ const GroupTimeline: React.FC = (props) => { useEffect(() => { dispatch(expandGroupTimeline(groupId)); + dispatch(expandGroupFeaturedTimeline(groupId)); dispatch(groupCompose(composeId, groupId)); const disconnect = dispatch(connectGroupStream(groupId)); @@ -123,6 +128,7 @@ const GroupTimeline: React.FC = (props) => { emptyMessageCard={false} divideType='border' showGroup={false} + featuredStatusIds={featuredStatusIds} /> ); diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 2541b0940c..bbfce9aa8b 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -1466,6 +1466,8 @@ "status.mute_conversation": "Mute Conversation", "status.open": "Show Post Details", "status.pin": "Pin on profile", + "status.pin_to_group": "Pin to Group", + "status.pin_to_group.success": "Pinned to Group!", "status.pinned": "Pinned post", "status.quote": "Quote post", "status.reactions.cry": "Sad", @@ -1502,6 +1504,7 @@ "status.unbookmarked": "Bookmark removed.", "status.unmute_conversation": "Unmute Conversation", "status.unpin": "Unpin from profile", + "status.unpin_to_group": "Unpin from Group", "status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}", "statuses.quote_tombstone": "Post is unavailable.", "statuses.tombstone": "One or more posts are unavailable.",