diff --git a/src/actions/interactions.ts b/src/actions/interactions.ts index cabcbb391..07ceb8f40 100644 --- a/src/actions/interactions.ts +++ b/src/actions/interactions.ts @@ -84,6 +84,10 @@ const ZAP_REQUEST = 'ZAP_REQUEST'; const ZAP_SUCCESS = 'ZAP_SUCCESS'; const ZAP_FAIL = 'ZAP_FAIL'; +const ZAPS_FETCH_REQUEST = 'ZAPS_FETCH_REQUEST'; +const ZAPS_FETCH_SUCCESS = 'ZAPS_FETCH_SUCCESS'; +const ZAPS_FETCH_FAIL = 'ZAPS_FETCH_FAIL'; + const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, @@ -625,6 +629,35 @@ const fetchReactionsFail = (id: string, error: unknown) => ({ error, }); +const fetchZaps = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchZapsRequest(id)); + + api(getState).get(`/api/v1/ditto/statuses/${id}/zapped_by`).then(response => { + dispatch(importFetchedAccounts((response.data as APIEntity[]).map(({ account }) => account).flat())); + dispatch(fetchZapsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchZapsFail(id, error)); + }); + }; + +const fetchZapsRequest = (id: string) => ({ + type: ZAPS_FETCH_REQUEST, + id, +}); + +const fetchZapsSuccess = (id: string, zaps: APIEntity[]) => ({ + type: ZAPS_FETCH_SUCCESS, + id, + zaps, +}); + +const fetchZapsFail = (id: string, error: unknown) => ({ + type: REACTIONS_FETCH_FAIL, + id, + error, +}); + const pin = (status: StatusEntity) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; @@ -802,6 +835,9 @@ export { REBLOGS_EXPAND_FAIL, ZAP_REQUEST, ZAP_FAIL, + ZAPS_FETCH_REQUEST, + ZAPS_FETCH_SUCCESS, + ZAPS_FETCH_FAIL, reblog, unreblog, toggleReblog, @@ -872,4 +908,5 @@ export { remoteInteractionSuccess, remoteInteractionFail, zap, + fetchZaps, }; diff --git a/src/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index 09d599c15..edfa10396 100644 --- a/src/components/status-action-bar.tsx +++ b/src/components/status-action-bar.tsx @@ -810,6 +810,7 @@ const StatusActionBar: React.FC = ({ active={status.zapped} text={withLabels ? intl.formatMessage(messages.zap) : undefined} theme={statusActionButtonTheme} + count={status?.zaps_amount ? status.zaps_amount / 1000 : 0} /> )} diff --git a/src/features/status/components/status-interaction-bar.tsx b/src/features/status/components/status-interaction-bar.tsx index 2e1809a22..cee040336 100644 --- a/src/features/status/components/status-interaction-bar.tsx +++ b/src/features/status/components/status-interaction-bar.tsx @@ -55,6 +55,13 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. })); }; + const onOpenZapsModal = (username: string, statusId: string): void => { + dispatch(openModal('ZAPS', { + username, + statusId, + })); + }; + const getNormalizedReacts = () => { return reduceEmoji( status.reactions, @@ -189,11 +196,36 @@ const StatusInteractionBar: React.FC = ({ status }): JSX. return null; }; + const handleOpenZapsModal = () => { + if (!me) { + return onOpenUnauthorizedModal(); + } + + onOpenZapsModal(account.acct, status.id); + }; + + const getZaps = () => { + if (status.zaps_amount) { + return ( + + + + ); + } + + return null; + }; + return ( {getReposts()} {getQuotes()} {(features.emojiReacts || features.emojiReactsMastodon) ? getEmojiReacts() : getFavourites()} + {getZaps()} {getDislikes()} ); diff --git a/src/features/ui/components/modal-root.tsx b/src/features/ui/components/modal-root.tsx index 3bda6fd3e..8aef4c7a7 100644 --- a/src/features/ui/components/modal-root.tsx +++ b/src/features/ui/components/modal-root.tsx @@ -44,6 +44,7 @@ import { EditRuleModal, ZapPayRequestModal, ZapInvoiceModal, + ZapsModal, } from 'soapbox/features/ui/util/async-components'; import ModalLoading from './modal-loading'; @@ -90,6 +91,7 @@ const MODAL_COMPONENTS: Record> = { 'SELECT_BOOKMARK_FOLDER': SelectBookmarkFolderModal, 'UNAUTHORIZED': UnauthorizedModal, 'VIDEO': VideoModal, + 'ZAPS': ZapsModal, 'ZAP_INVOICE': ZapInvoiceModal, 'ZAP_PAY_REQUEST': ZapPayRequestModal, }; diff --git a/src/features/ui/components/modals/zaps-modal.tsx b/src/features/ui/components/modals/zaps-modal.tsx new file mode 100644 index 000000000..f562cd03b --- /dev/null +++ b/src/features/ui/components/modals/zaps-modal.tsx @@ -0,0 +1,87 @@ +import { List as ImmutableList } from 'immutable'; +import React, { useEffect, useMemo } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { fetchZaps } from 'soapbox/actions/interactions'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Modal, Spinner, Text } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account-container'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +interface IAccountWithZaps { + id: string; + zap_comment: string; + zap_amount: number; +} + +interface IZapsModal { + onClose: (string: string) => void; + statusId: string; +} + +const ZapsModal: React.FC = ({ onClose, statusId }) => { + const dispatch = useAppDispatch(); + const zaps = useAppSelector((state) => state.user_lists.zapped_by.get(statusId)?.items); + + + const accounts = useMemo((): ImmutableList | undefined => { + if (!zaps) return; + + return zaps.map(({ account, zap_amount, zap_comment }) =>({ id: account, zap_amount, zap_comment })).flatten() as ImmutableList; + }, [zaps]); + + const fetchData = () => { + dispatch(fetchZaps(statusId)); + }; + + useEffect(() => { + fetchData(); + }, []); + + const onClickClose = () => { + onClose('ZAPS'); + }; + + let body; + + if (!zaps || !accounts) { + body = ; + } else { + const emptyMessage = ; + + body = ( + + {accounts.map((account) => { + return ( +
+ + {shortNumberFormat(account.zap_amount / 1000)} + + +
+ ); + }, + )} +
+ ); + } + + return ( + } + onClose={onClickClose} + > + {body} + + ); +}; + +export default ZapsModal; diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 85d1b9bb9..b7c739d9c 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -178,3 +178,4 @@ export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/m export const AdminNostrRelays = lazy(() => import('soapbox/features/admin/nostr-relays')); export const ZapPayRequestModal = lazy(() => import('soapbox/features/ui/components/modals/zap-pay-request')); export const ZapInvoiceModal = lazy(() => import('soapbox/features/ui/components/modals/zap-invoice')); +export const ZapsModal = lazy(() => import('soapbox/features/ui/components/modals/zaps-modal')); diff --git a/src/locales/en.json b/src/locales/en.json index 0785a7df0..d373b6b39 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -436,6 +436,7 @@ "column.settings_store": "Settings store", "column.soapbox_config": "Soapbox config", "column.test": "Test timeline", + "column.zaps": "Zaps", "column_forbidden.body": "You do not have permission to access this page.", "column_forbidden.title": "Forbidden", "common.cancel": "Cancel", @@ -1519,6 +1520,7 @@ "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}}", + "status.interactions.zaps": "{count, plural, one {Zap} other {Zaps}}", "status.load_more": "Load more", "status.mention": "Mention @{name}", "status.more": "More", @@ -1566,6 +1568,7 @@ "status.unpin": "Unpin from profile", "status.unpin_to_group": "Unpin from Group", "status.zap": "Zap", + "status.zaps.empty": "No one has zapped this post yet. When someone does, they will show up here.", "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.", diff --git a/src/normalizers/status.ts b/src/normalizers/status.ts index 4e5941a3d..1797feb2e 100644 --- a/src/normalizers/status.ts +++ b/src/normalizers/status.ts @@ -75,6 +75,7 @@ export const StatusRecord = ImmutableRecord({ reblogged: false, reblogs_count: 0, replies_count: 0, + zaps_amount: 0, sensitive: false, spoiler_text: '', tags: ImmutableList>(), diff --git a/src/reducers/user-lists.ts b/src/reducers/user-lists.ts index f5b7da7c0..518a43d6c 100644 --- a/src/reducers/user-lists.ts +++ b/src/reducers/user-lists.ts @@ -64,6 +64,7 @@ import { FAVOURITES_EXPAND_SUCCESS, DISLIKES_FETCH_SUCCESS, REACTIONS_FETCH_SUCCESS, + ZAPS_FETCH_SUCCESS, } from 'soapbox/actions/interactions'; import { NOTIFICATIONS_UPDATE, @@ -90,6 +91,17 @@ const ReactionListRecord = ImmutableRecord({ isLoading: false, }); +export const ZapRecord = ImmutableRecord({ + account: '', + zap_comment: '', + zap_amount: 0, // in millisats +}); + +const ZapListRecord = ImmutableRecord({ + items: ImmutableOrderedSet(), + isLoading: false, +}); + export const ParticipationRequestRecord = ImmutableRecord({ account: '', participation_message: null as string | null, @@ -108,6 +120,7 @@ export const ReducerRecord = ImmutableRecord({ favourited_by: ImmutableMap(), disliked_by: ImmutableMap(), reactions: ImmutableMap(), + zapped_by: ImmutableMap(), follow_requests: ListRecord(), blocks: ListRecord(), mutes: ListRecord(), @@ -125,10 +138,12 @@ type State = ReturnType; export type List = ReturnType; type Reaction = ReturnType; type ReactionList = ReturnType; +type Zap = ReturnType; +type ZapList = ReturnType; type ParticipationRequest = ReturnType; type ParticipationRequestList = ReturnType; type Items = ImmutableOrderedSet; -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 NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'disliked_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests' | 'membership_requests' | 'group_blocks' | 'zapped_by', string]; type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory']; const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => { @@ -186,6 +201,13 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) { accounts: ImmutableOrderedSet(accounts.map((account: APIEntity) => account.id)), }))), })); + case ZAPS_FETCH_SUCCESS: + return state.setIn(['zapped_by', action.id], ZapListRecord({ + items: ImmutableOrderedSet(action.zaps.map(({ account, ...zap }: APIEntity) => ZapRecord({ + ...zap, + account: account.id, + }))), + })); case NOTIFICATIONS_UPDATE: return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; case FOLLOW_REQUESTS_FETCH_SUCCESS: