diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index b2517301f..77bccb41f 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 902b99f70..1df112dae 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 7d3d540da..3e6cae67a 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'; @@ -67,6 +67,9 @@ const messages = defineMessages({ 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' }, @@ -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 a920a862c..3c736cd78 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 2541b0940..bbfce9aa8 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.",