From 12f3b4fbc3d4a9d7466ee803ea2d1695229748c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 25 Mar 2023 23:16:40 +0100 Subject: [PATCH] Support Friendica dislikes, quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/interactions.ts | 53 ++++++++++++++-- app/soapbox/components/status-action-bar.tsx | 4 +- .../components/status-interaction-bar.tsx | 33 ++++++++++ .../features/ui/components/modal-root.tsx | 2 + .../ui/components/modals/dislikes-modal.tsx | 63 +++++++++++++++++++ .../features/ui/util/async-components.ts | 4 ++ app/soapbox/normalizers/status.ts | 14 ++++- app/soapbox/reducers/statuses.ts | 28 +++++++++ app/soapbox/reducers/user-lists.ts | 6 +- app/soapbox/utils/features.ts | 3 +- 10 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 app/soapbox/features/ui/components/modals/dislikes-modal.tsx diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index eb046098e..40d981139 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -44,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'; @@ -104,7 +108,7 @@ const unreblog = (status: StatusEntity) => }; const toggleReblog = (status: StatusEntity) => - (dispatch: AppDispatch, getState: () => RootState) => { + (dispatch: AppDispatch) => { if (status.reblogged) { dispatch(unreblog(status)); } else { @@ -177,7 +181,7 @@ const unfavourite = (status: StatusEntity) => }; const toggleFavourite = (status: StatusEntity) => - (dispatch: AppDispatch, getState: () => RootState) => { + (dispatch: AppDispatch) => { if (status.favourited) { dispatch(unfavourite(status)); } else { @@ -229,7 +233,7 @@ const dislike = (status: StatusEntity) => dispatch(dislikeRequest(status)); - api(getState).post(`/api/friendica/${status.get('id')}/dislike`).then(function() { + api(getState).post(`/api/friendica/statuses/${status.get('id')}/dislike`).then(function() { dispatch(dislikeSuccess(status)); }).catch(function(error) { dispatch(dislikeFail(status, error)); @@ -242,7 +246,7 @@ const undislike = (status: StatusEntity) => dispatch(undislikeRequest(status)); - api(getState).post(`/api/friendica/${status.get('id')}/undislike`).then(() => { + api(getState).post(`/api/friendica/statuses/${status.get('id')}/undislike`).then(() => { dispatch(undislikeSuccess(status)); }).catch(error => { dispatch(undislikeFail(status, error)); @@ -250,7 +254,7 @@ const undislike = (status: StatusEntity) => }; const toggleDislike = (status: StatusEntity) => - (dispatch: AppDispatch, getState: () => RootState) => { + (dispatch: AppDispatch) => { if (status.disliked) { dispatch(undislike(status)); } else { @@ -432,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)); @@ -597,6 +633,9 @@ export { 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, @@ -659,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 2123c44e9..9d8560f00 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -671,8 +671,8 @@ const StatusActionBar: React.FC = ({ color='accent' filled onClick={handleDislikeClick} - active={status.friendica.get('disliked')} - count={status.friendica.get('dislikes_count')} + active={status.disliked} + count={status.dislikes_count} text={withLabels ? intl.formatMessage(messages.disfavourite) : undefined } /> )} diff --git a/app/soapbox/features/status/components/status-interaction-bar.tsx b/app/soapbox/features/status/components/status-interaction-bar.tsx index 4d0333c06..87b9a234c 100644 --- a/app/soapbox/features/status/components/status-interaction-bar.tsx +++ b/app/soapbox/features/status/components/status-interaction-bar.tsx @@ -46,6 +46,13 @@ const StatusInteractionBar: React.FC = ({ 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/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/normalizers/status.ts b/app/soapbox/normalizers/status.ts index b60d927c8..a09cf9d56 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -46,12 +46,13 @@ 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, favourites_count: 0, filtered: ImmutableList(), - friendica: ImmutableMap(), group: null as EmbeddedEntity, in_reply_to_account_id: null as string | null, in_reply_to_id: null as string | null, @@ -218,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 => { @@ -233,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 483cccc68..441cde494 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -348,7 +348,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/friendica/statuses/:id/undislike * @see GET /api/friendica/statuses/:id/disliked_by */ - dislikes: v.software === FRIENDICA && gte(v.version, '2023.03.0'), + dislikes: v.software === FRIENDICA && gte(v.version, '2023.3.0'), /** * Ability to edit profile information. @@ -723,6 +723,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,