diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4937018..52a7c4d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Posts: Support posts filtering on recent Mastodon versions - Reactions: Support custom emoji reactions - Compatbility: Support Mastodon v2 timeline filters. +- Posts: Support dislikes on Friendica. ### Changed - Posts: truncate Nostr pubkeys in reply mentions. @@ -24,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile: fix "load more" button height on account gallery page. - 18n: fixed Chinese language being detected from the browser. - Conversations: fixed pagination (Mastodon). +- Compatibility: fix version parsing for Friendica. ## [3.2.0] - 2023-02-15 diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 9e43d0f40..40d981139 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -20,6 +20,10 @@ const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; +const DISLIKE_REQUEST = 'DISLIKE_REQUEST'; +const DISLIKE_SUCCESS = 'DISLIKE_SUCCESS'; +const DISLIKE_FAIL = 'DISLIKE_FAIL'; + const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; @@ -28,6 +32,10 @@ const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; +const UNDISLIKE_REQUEST = 'UNDISLIKE_REQUEST'; +const UNDISLIKE_SUCCESS = 'UNDISLIKE_SUCCESS'; +const UNDISLIKE_FAIL = 'UNDISLIKE_FAIL'; + const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; @@ -36,6 +44,10 @@ const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; +const DISLIKES_FETCH_REQUEST = 'DISLIKES_FETCH_REQUEST'; +const DISLIKES_FETCH_SUCCESS = 'DISLIKES_FETCH_SUCCESS'; +const DISLIKES_FETCH_FAIL = 'DISLIKES_FETCH_FAIL'; + const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; @@ -96,7 +108,7 @@ const unreblog = (status: StatusEntity) => }; const toggleReblog = (status: StatusEntity) => - (dispatch: AppDispatch, getState: () => RootState) => { + (dispatch: AppDispatch) => { if (status.reblogged) { dispatch(unreblog(status)); } else { @@ -169,7 +181,7 @@ const unfavourite = (status: StatusEntity) => }; const toggleFavourite = (status: StatusEntity) => - (dispatch: AppDispatch, getState: () => RootState) => { + (dispatch: AppDispatch) => { if (status.favourited) { dispatch(unfavourite(status)); } else { @@ -215,6 +227,79 @@ const unfavouriteFail = (status: StatusEntity, error: AxiosError) => ({ skipLoading: true, }); +const dislike = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(dislikeRequest(status)); + + api(getState).post(`/api/friendica/statuses/${status.get('id')}/dislike`).then(function() { + dispatch(dislikeSuccess(status)); + }).catch(function(error) { + dispatch(dislikeFail(status, error)); + }); + }; + +const undislike = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(undislikeRequest(status)); + + api(getState).post(`/api/friendica/statuses/${status.get('id')}/undislike`).then(() => { + dispatch(undislikeSuccess(status)); + }).catch(error => { + dispatch(undislikeFail(status, error)); + }); + }; + +const toggleDislike = (status: StatusEntity) => + (dispatch: AppDispatch) => { + if (status.disliked) { + dispatch(undislike(status)); + } else { + dispatch(dislike(status)); + } + }; + +const dislikeRequest = (status: StatusEntity) => ({ + type: DISLIKE_REQUEST, + status: status, + skipLoading: true, +}); + +const dislikeSuccess = (status: StatusEntity) => ({ + type: DISLIKE_SUCCESS, + status: status, + skipLoading: true, +}); + +const dislikeFail = (status: StatusEntity, error: AxiosError) => ({ + type: DISLIKE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const undislikeRequest = (status: StatusEntity) => ({ + type: UNDISLIKE_REQUEST, + status: status, + skipLoading: true, +}); + +const undislikeSuccess = (status: StatusEntity) => ({ + type: UNDISLIKE_SUCCESS, + status: status, + skipLoading: true, +}); + +const undislikeFail = (status: StatusEntity, error: AxiosError) => ({ + type: UNDISLIKE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + const bookmark = (status: StatusEntity) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(bookmarkRequest(status)); @@ -351,6 +436,38 @@ const fetchFavouritesFail = (id: string, error: AxiosError) => ({ error, }); +const fetchDislikes = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchDislikesRequest(id)); + + api(getState).get(`/api/friendica/statuses/${id}/disliked_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + dispatch(fetchDislikesSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchDislikesFail(id, error)); + }); + }; + +const fetchDislikesRequest = (id: string) => ({ + type: DISLIKES_FETCH_REQUEST, + id, +}); + +const fetchDislikesSuccess = (id: string, accounts: APIEntity[]) => ({ + type: DISLIKES_FETCH_SUCCESS, + id, + accounts, +}); + +const fetchDislikesFail = (id: string, error: AxiosError) => ({ + type: DISLIKES_FETCH_FAIL, + id, + error, +}); + const fetchReactions = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(fetchReactionsRequest(id)); @@ -498,18 +615,27 @@ export { FAVOURITE_REQUEST, FAVOURITE_SUCCESS, FAVOURITE_FAIL, + DISLIKE_REQUEST, + DISLIKE_SUCCESS, + DISLIKE_FAIL, UNREBLOG_REQUEST, UNREBLOG_SUCCESS, UNREBLOG_FAIL, UNFAVOURITE_REQUEST, UNFAVOURITE_SUCCESS, UNFAVOURITE_FAIL, + UNDISLIKE_REQUEST, + UNDISLIKE_SUCCESS, + UNDISLIKE_FAIL, REBLOGS_FETCH_REQUEST, REBLOGS_FETCH_SUCCESS, REBLOGS_FETCH_FAIL, FAVOURITES_FETCH_REQUEST, FAVOURITES_FETCH_SUCCESS, FAVOURITES_FETCH_FAIL, + DISLIKES_FETCH_REQUEST, + DISLIKES_FETCH_SUCCESS, + DISLIKES_FETCH_FAIL, REACTIONS_FETCH_REQUEST, REACTIONS_FETCH_SUCCESS, REACTIONS_FETCH_FAIL, @@ -546,6 +672,15 @@ export { unfavouriteRequest, unfavouriteSuccess, unfavouriteFail, + dislike, + undislike, + toggleDislike, + dislikeRequest, + dislikeSuccess, + dislikeFail, + undislikeRequest, + undislikeSuccess, + undislikeFail, bookmark, unbookmark, toggleBookmark, @@ -563,6 +698,10 @@ export { fetchFavouritesRequest, fetchFavouritesSuccess, fetchFavouritesFail, + fetchDislikes, + fetchDislikesRequest, + fetchDislikesSuccess, + fetchDislikesFail, fetchReactions, fetchReactionsRequest, fetchReactionsSuccess, diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 479095e66..a17dc7194 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -8,7 +8,7 @@ import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { editEvent } from 'soapbox/actions/events'; import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups'; -import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; +import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; @@ -45,6 +45,7 @@ const messages = defineMessages({ 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' }, @@ -161,6 +162,14 @@ const StatusActionBar: React.FC = ({ } }; + const handleDislikeClick: React.EventHandler = (e) => { + if (me) { + dispatch(toggleDislike(status)); + } else { + onOpenUnauthorizedModal('DISLIKE'); + } + }; + const handleBookmarkClick: React.EventHandler = (e) => { dispatch(toggleBookmark(status)); }; @@ -645,7 +654,7 @@ const StatusActionBar: React.FC = ({ ) : ( = ({ /> )} + {features.dislikes && ( + + )} + {canShare && ( = ({ status }): JSX. })); }; + const onOpenDislikesModal = (username: string, statusId: string): void => { + dispatch(openModal('DISLIKES', { + username, + statusId, + })); + }; + const onOpenReactionsModal = (username: string, statusId: string): void => { dispatch(openModal('REACTIONS', { username, @@ -114,6 +121,13 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. else onOpenFavouritesModal(account.acct, status.id); }; + const handleOpenDislikesModal: React.EventHandler> = (e) => { + e.preventDefault(); + + if (!me) onOpenUnauthorizedModal(); + else onOpenDislikesModal(account.acct, status.id); + }; + const getFavourites = () => { if (status.favourites_count) { return ( @@ -130,6 +144,24 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. return null; }; + const getDislikes = () => { + const dislikesCount = status.dislikes_count; + + if (dislikesCount) { + return ( + + + + ); + } + + return null; + }; + const handleOpenReactionsModal = () => { if (!me) { return onOpenUnauthorizedModal(); @@ -171,6 +203,7 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. {getReposts()} {getQuotes()} {features.emojiReacts ? getEmojiReacts() : getFavourites()} + {getDislikes()} ); }; diff --git a/app/soapbox/features/ui/components/modal-root.tsx b/app/soapbox/features/ui/components/modal-root.tsx index 63911d82f..55e53c268 100644 --- a/app/soapbox/features/ui/components/modal-root.tsx +++ b/app/soapbox/features/ui/components/modal-root.tsx @@ -13,6 +13,7 @@ import { ComposeModal, ConfirmationModal, CryptoDonateModal, + DislikesModal, EditAnnouncementModal, EditFederationModal, EmbedModal, @@ -59,6 +60,7 @@ const MODAL_COMPONENTS = { 'COMPOSE_EVENT': ComposeEventModal, 'CONFIRM': ConfirmationModal, 'CRYPTO_DONATE': CryptoDonateModal, + 'DISLIKES': DislikesModal, 'EDIT_ANNOUNCEMENT': EditAnnouncementModal, 'EDIT_FEDERATION': EditFederationModal, 'EMBED': EmbedModal, diff --git a/app/soapbox/features/ui/components/modals/dislikes-modal.tsx b/app/soapbox/features/ui/components/modals/dislikes-modal.tsx new file mode 100644 index 000000000..1522bcc81 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/dislikes-modal.tsx @@ -0,0 +1,63 @@ +import React, { useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { fetchDislikes } from 'soapbox/actions/interactions'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Modal, Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account-container'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +interface IDislikesModal { + onClose: (type: string) => void + statusId: string +} + +const DislikesModal: React.FC = ({ onClose, statusId }) => { + const dispatch = useAppDispatch(); + + const accountIds = useAppSelector((state) => state.user_lists.disliked_by.get(statusId)?.items); + + const fetchData = () => { + dispatch(fetchDislikes(statusId)); + }; + + useEffect(() => { + fetchData(); + }, []); + + const onClickClose = () => { + onClose('DISLIKES'); + }; + + let body; + + if (!accountIds) { + body = ; + } else { + const emptyMessage = ; + + body = ( + + {accountIds.map(id => + , + )} + + ); + } + + return ( + } + onClose={onClickClose} + > + {body} + + ); +}; + +export default DislikesModal; diff --git a/app/soapbox/features/ui/components/modals/unauthorized-modal.tsx b/app/soapbox/features/ui/components/modals/unauthorized-modal.tsx index 24f4570f8..d5de86ba7 100644 --- a/app/soapbox/features/ui/components/modals/unauthorized-modal.tsx +++ b/app/soapbox/features/ui/components/modals/unauthorized-modal.tsx @@ -15,7 +15,7 @@ const messages = defineMessages({ interface IUnauthorizedModal { /** Unauthorized action type. */ - action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE' | 'JOIN' + action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'DISLIKE' | 'POLL_VOTE' | 'JOIN' /** Close event handler. */ onClose: (modalType: string) => void /** ActivityPub ID of the account OR status being acted upon. */ @@ -86,6 +86,9 @@ const UnauthorizedModal: React.FC = ({ action, onClose, acco } else if (action === 'FAVOURITE') { header = ; button = ; + } else if (action === 'DISLIKE') { + header = ; + button = ; } else if (action === 'POLL_VOTE') { header = ; button = ; diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 63d2794e2..78f81cc20 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -190,6 +190,10 @@ export function FavouritesModal() { return import(/* webpackChunkName: "features/ui" */'../components/modals/favourites-modal'); } +export function DislikesModal() { + return import(/* webpackChunkName: "features/ui" */'../components/modals/dislikes-modal'); +} + export function ReactionsModal() { return import(/* webpackChunkName: "features/ui" */'../components/modals/reactions-modal'); } diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 015bb74ff..f8ac161d5 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -316,6 +316,7 @@ "column.developers.service_worker": "Service Worker", "column.direct": "Direct messages", "column.directory": "Browse profiles", + "column.dislikes": "Dislikes", "column.domain_blocks": "Hidden domains", "column.edit_profile": "Edit profile", "column.event_map": "Event location", @@ -661,6 +662,7 @@ "empty_column.bookmarks": "You don't have any bookmarks yet. When you add one, it will show up here.", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", + "empty_column.dislikes": "No one has disliked this post yet. When someone does, they will show up here.", "empty_column.domain_blocks": "There are no hidden domains yet.", "empty_column.event_participant_requests": "There are no pending event participation requests.", "empty_column.event_participants": "No one joined this event yet. When someone does, they will show up here.", @@ -1214,6 +1216,8 @@ "remote_instance.pin_host": "Pin {host}", "remote_instance.unpin_host": "Unpin {host}", "remote_interaction.account_placeholder": "Enter your username@domain you want to act from", + "remote_interaction.dislike": "Proceed to dislike", + "remote_interaction.dislike_title": "Dislike a post remotely", "remote_interaction.divider": "or", "remote_interaction.event_join": "Proceed to join", "remote_interaction.event_join_title": "Join an event remotely", @@ -1397,6 +1401,7 @@ "status.detailed_status": "Detailed conversation view", "status.direct": "Direct message @{name}", "status.disabled_replies.group_membership": "Only group members can reply", + "status.disfavourite": "Disike", "status.edit": "Edit", "status.embed": "Embed post", "status.external": "View post on {domain}", @@ -1406,6 +1411,7 @@ "status.group_mod_block": "Block @{name} from group", "status.group_mod_delete": "Delete post from group", "status.group_mod_kick": "Kick @{name} from group", + "status.interactions.dislikes": "{count, plural, one {Dislike} other {Dislikes}}", "status.interactions.favourites": "{count, plural, one {Like} other {Likes}}", "status.interactions.quotes": "{count, plural, one {Quote} other {Quotes}}", "status.interactions.reblogs": "{count, plural, one {Repost} other {Reposts}}", diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 3de7f6f4f..031c99c29 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -46,6 +46,8 @@ export const StatusRecord = ImmutableRecord({ card: null as Card | null, content: '', created_at: '', + dislikes_count: 0, + disliked: false, edited_at: null as string | null, emojis: ImmutableList(), favourited: false, @@ -217,6 +219,16 @@ const normalizeFilterResults = (status: ImmutableMap) => ), ); +const normalizeDislikes = (status: ImmutableMap) => { + if (status.get('friendica')) { + return status + .set('dislikes_count', status.getIn(['friendica', 'dislikes_count'])) + .set('disliked', status.getIn(['friendica', 'disliked'])); + } + + return status; +}; + export const normalizeStatus = (status: Record) => { return StatusRecord( ImmutableMap(fromJS(status)).withMutations(status => { @@ -232,6 +244,7 @@ export const normalizeStatus = (status: Record) => { normalizeEvent(status); fixContent(status); normalizeFilterResults(status); + normalizeDislikes(status); }), ); }; diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 11e4846a6..3ae9c308f 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -26,6 +26,9 @@ import { FAVOURITE_REQUEST, UNFAVOURITE_REQUEST, FAVOURITE_FAIL, + DISLIKE_REQUEST, + UNDISLIKE_REQUEST, + DISLIKE_FAIL, } from '../actions/interactions'; import { STATUS_CREATE_REQUEST, @@ -204,6 +207,25 @@ const simulateFavourite = ( return state.set(statusId, updatedStatus); }; +/** Simulate dislike/undislike of status for optimistic interactions */ +const simulateDislike = ( + state: State, + statusId: string, + disliked: boolean, +): State => { + const status = state.get(statusId); + if (!status) return state; + + const delta = disliked ? +1 : -1; + + const updatedStatus = status.merge({ + disliked, + dislikes_count: Math.max(0, status.dislikes_count + delta), + }); + + return state.set(statusId, updatedStatus); +}; + interface Translation { content: string detected_source_language: string @@ -238,6 +260,10 @@ export default function statuses(state = initialState, action: AnyAction): State return simulateFavourite(state, action.status.id, true); case UNFAVOURITE_REQUEST: return simulateFavourite(state, action.status.id, false); + case DISLIKE_REQUEST: + return simulateDislike(state, action.status.id, true); + case UNDISLIKE_REQUEST: + return simulateDislike(state, action.status.id, false); case EMOJI_REACT_REQUEST: return state .updateIn( @@ -252,6 +278,8 @@ export default function statuses(state = initialState, action: AnyAction): State ); case FAVOURITE_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); + case DISLIKE_FAIL: + return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'disliked'], false); case REBLOG_REQUEST: return state.setIn([action.status.get('id'), 'reblogged'], true); case REBLOG_FAIL: diff --git a/app/soapbox/reducers/user-lists.ts b/app/soapbox/reducers/user-lists.ts index c979e9c46..3cb1f9205 100644 --- a/app/soapbox/reducers/user-lists.ts +++ b/app/soapbox/reducers/user-lists.ts @@ -60,6 +60,7 @@ import { import { REBLOGS_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS, + DISLIKES_FETCH_SUCCESS, REACTIONS_FETCH_SUCCESS, } from 'soapbox/actions/interactions'; import { @@ -107,6 +108,7 @@ export const ReducerRecord = ImmutableRecord({ following: ImmutableMap(), reblogged_by: ImmutableMap(), favourited_by: ImmutableMap(), + disliked_by: ImmutableMap(), reactions: ImmutableMap(), follow_requests: ListRecord(), blocks: ListRecord(), @@ -128,7 +130,7 @@ type ReactionList = ReturnType; type ParticipationRequest = ReturnType; type ParticipationRequestList = ReturnType; type Items = ImmutableOrderedSet; -type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests' | 'membership_requests' | 'group_blocks', string]; +type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'disliked_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests' | 'membership_requests' | 'group_blocks', string]; type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory']; const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => { @@ -173,6 +175,8 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) { return normalizeList(state, ['reblogged_by', action.id], action.accounts); case FAVOURITES_FETCH_SUCCESS: return normalizeList(state, ['favourited_by', action.id], action.accounts); + case DISLIKES_FETCH_SUCCESS: + return normalizeList(state, ['disliked_by', action.id], action.accounts); case REACTIONS_FETCH_SUCCESS: return state.setIn(['reactions', action.id], ReactionListRecord({ items: ImmutableOrderedSet(action.reactions.map(({ accounts, ...reaction }: APIEntity) => ReactionRecord({ diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 631a49ac3..5230546b5 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -343,6 +343,13 @@ const getInstanceFeatures = (instance: Instance) => { v.software === PLEROMA && gte(v.version, '0.9.9'), ]), + /** + * @see POST /api/friendica/statuses/:id/dislike + * @see POST /api/friendica/statuses/:id/undislike + * @see GET /api/friendica/statuses/:id/disliked_by + */ + dislikes: v.software === FRIENDICA && gte(v.version, '2023.3.0'), + /** * Ability to edit profile information. * @see PATCH /api/v1/accounts/update_credentials @@ -721,6 +728,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/statuses */ quotePosts: any([ + v.software === FRIENDICA && gte(v.version, '2023.3.0'), v.software === PLEROMA && [REBASED, AKKOMA].includes(v.build!) && gte(v.version, '2.4.50'), features.includes('quote_posting'), instance.feature_quote === true,