diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts index f9417a6c0..6413fe268 100644 --- a/app/soapbox/actions/events.ts +++ b/app/soapbox/actions/events.ts @@ -35,9 +35,19 @@ const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST'; const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS'; const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL'; +const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST'; +const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS'; +const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL'; + +const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST'; +const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS'; +const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL'; + const messages = defineMessages({ exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, success: { id: 'create_event.submit_success', defaultMessage: 'Your event was created' }, + joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' }, + joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' }, view: { id: 'snackbar.view', defaultMessage: 'View' }, }); @@ -191,8 +201,6 @@ const submitEvent = () => dispatch(submitEventRequest()); - const idempotencyKey = state.compose.idempotencyKey; - const params: Record = { name, status, @@ -206,9 +214,9 @@ const submitEvent = () => return api(getState).post('/api/v1/pleroma/events', params).then(({ data }) => { dispatch(closeModal('CREATE_EVENT')); - dispatch(importFetchedStatus(data, idempotencyKey)); + dispatch(importFetchedStatus(data)); dispatch(submitEventSuccess(data)); - dispatch(snackbar.success(messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); + dispatch(snackbar.success(messages.success, messages.view, `/@${data.account.acct}/events/${data.id}`)); }).catch(function(error) { dispatch(submitEventFail(error)); }); @@ -228,6 +236,71 @@ const submitEventFail = (error: AxiosError) => ({ error: error, }); +const joinEvent = (id: string, participationMessage?: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id); + + if (!status || !status.event || status.event.join_state) return; + + dispatch(joinEventRequest()); + + return api(getState).post(`/api/v1/pleroma/events/${id}/join`, { participationMessage }).then(({ data }) => { + dispatch(importFetchedStatus(data)); + dispatch(joinEventSuccess(data)); + dispatch(snackbar.success( + data.pleroma.event?.join_state === 'pending' ? messages.joinRequestSuccess : messages.joinSuccess, + messages.view, + `/@${data.account.acct}/events/${data.id}`, + )); + }).catch(function(error) { + dispatch(joinEventFail(error)); + }); + }; + +const joinEventRequest = () => ({ + type: EVENT_JOIN_REQUEST, +}); + +const joinEventSuccess = (status: APIEntity) => ({ + type: EVENT_JOIN_SUCCESS, + status: status, +}); + +const joinEventFail = (error: AxiosError) => ({ + type: EVENT_JOIN_FAIL, + error: error, +}); + +const leaveEvent = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const status = getState().statuses.get(id); + + if (!status || !status.event || !status.event.join_state) return; + + dispatch(leaveEventRequest()); + + return api(getState).post(`/api/v1/pleroma/events/${id}/leave`).then(({ data }) => { + dispatch(importFetchedStatus(data)); + dispatch(leaveEventSuccess(data)); + }).catch(function(error) { + dispatch(leaveEventFail(error)); + }); + }; + +const leaveEventRequest = () => ({ + type: EVENT_LEAVE_REQUEST, +}); + +const leaveEventSuccess = (status: APIEntity) => ({ + type: EVENT_LEAVE_SUCCESS, + status: status, +}); + +const leaveEventFail = (error: AxiosError) => ({ + type: EVENT_LEAVE_FAIL, + error: error, +}); + export { LOCATION_SEARCH_REQUEST, LOCATION_SEARCH_SUCCESS, @@ -247,6 +320,12 @@ export { EVENT_SUBMIT_REQUEST, EVENT_SUBMIT_SUCCESS, EVENT_SUBMIT_FAIL, + EVENT_JOIN_REQUEST, + EVENT_JOIN_SUCCESS, + EVENT_JOIN_FAIL, + EVENT_LEAVE_REQUEST, + EVENT_LEAVE_SUCCESS, + EVENT_LEAVE_FAIL, locationSearch, changeCreateEventName, changeCreateEventDescription, @@ -265,4 +344,12 @@ export { submitEventRequest, submitEventSuccess, submitEventFail, + joinEvent, + joinEventRequest, + joinEventSuccess, + joinEventFail, + leaveEvent, + leaveEventRequest, + leaveEventSuccess, + leaveEventFail, }; diff --git a/app/soapbox/components/event-preview.tsx b/app/soapbox/components/event-preview.tsx index bf9ef4353..e1da5af62 100644 --- a/app/soapbox/components/event-preview.tsx +++ b/app/soapbox/components/event-preview.tsx @@ -1,7 +1,10 @@ import React, { useCallback } from 'react'; -import { FormattedDate, FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedDate, FormattedMessage, useIntl } from 'react-intl'; -import { useAppSelector } from 'soapbox/hooks'; +import { joinEvent, leaveEvent } from 'soapbox/actions/events'; +import { openModal } from 'soapbox/actions/modals'; +import { buildStatus } from 'soapbox/features/scheduled_statuses/builder'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import Icon from './icon'; import { Button, HStack, Stack, Text } from './ui'; @@ -9,11 +12,19 @@ import VerificationBadge from './verification_badge'; import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; +const messages = defineMessages({ + leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' }, + leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' }, +}); + interface IEventPreview { status: StatusEntity, } const EventPreview: React.FC = ({ status }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const me = useAppSelector((state) => state.me); const account = status.account as AccountEntity; @@ -21,6 +32,32 @@ const EventPreview: React.FC = ({ status }) => { const banner = status.media_attachments?.find(({ description }) => description === 'Banner'); + const handleJoin: React.EventHandler = (e) => { + e.preventDefault(); + + if (event.join_mode === 'free') { + dispatch(joinEvent(status.id)); + } else { + dispatch(openModal('JOIN_EVENT', { + statusId: status.id, + })); + } + }; + + const handleLeave: React.EventHandler = (e) => { + e.preventDefault(); + + if (event.join_mode === 'restricted') { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.leaveMessage), + confirm: intl.formatMessage(messages.leaveConfirm), + onConfirm: () => dispatch(leaveEvent(status.id)), + })); + } else { + dispatch(leaveEvent(status.id)); + } + }; + const renderDate = useCallback(() => { if (!event.start_time) return null; @@ -64,6 +101,43 @@ const EventPreview: React.FC = ({ status }) => { ); }, [event.start_time, event.end_time]); + const renderAction = useCallback(() => { + let buttonLabel; + let buttonIcon; + let buttonDisabled = false; + let buttonAction = handleLeave; + + switch (event.join_state) { + case 'accept': + buttonLabel = ; + buttonIcon = require('@tabler/icons/check.svg'); + break; + case 'pending': + buttonLabel = ; + break; + case 'reject': + buttonLabel = ; + buttonIcon = require('@tabler/icons/ban.svg'); + buttonDisabled = true; + break; + default: + buttonLabel = ; + buttonAction = handleJoin; + } + + return ( + + ); + }, [event.join_state]); + return (
@@ -75,22 +149,7 @@ const EventPreview: React.FC = ({ status }) => { > - ) : event.join_state === 'accept' ? ( - - ) : ( - - )} + ) : renderAction()}
{banner && {banner.url}} diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index 050d705b6..0f7b8c025 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -33,6 +33,7 @@ import { VerifySmsModal, FamiliarFollowersModal, CreateEventModal, + JoinEventModal, } from 'soapbox/features/ui/util/async-components'; import BundleContainer from '../containers/bundle_container'; @@ -71,6 +72,7 @@ const MODAL_COMPONENTS = { 'VERIFY_SMS': VerifySmsModal, 'FAMILIAR_FOLLOWERS': FamiliarFollowersModal, 'CREATE_EVENT': CreateEventModal, + 'JOIN_EVENT': JoinEventModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/soapbox/features/ui/components/modals/join-event-modal.tsx b/app/soapbox/features/ui/components/modals/join-event-modal.tsx new file mode 100644 index 000000000..c3a7743fa --- /dev/null +++ b/app/soapbox/features/ui/components/modals/join-event-modal.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { joinEvent } from 'soapbox/actions/events'; +import { closeModal } from 'soapbox/actions/modals'; +import { Modal, Text } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; + +const messages = defineMessages({ + placeholder: { id: 'join_event.placeholder', defaultMessage: 'Message to organizer' }, + join: { id: 'join_event.join', defaultMessage: 'Request join' }, +}); + +interface IAccountNoteModal { + statusId: string, +} + +const AccountNoteModal: React.FC = ({ statusId }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const [participationMessage, setParticipationMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onClose = () => { + dispatch(closeModal('JOIN_EVENT')); + }; + + const handleChange: React.ChangeEventHandler = e => { + setParticipationMessage(e.target.value); + }; + + const handleSubmit = () => { + setIsSubmitting(true); + dispatch(joinEvent(statusId, participationMessage))?.then(() => { + onClose(); + }).catch(() => {}); + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + handleSubmit(); + } + }; + + return ( + } + onClose={onClose} + confirmationAction={handleSubmit} + confirmationText={intl.formatMessage(messages.join)} + confirmationDisabled={isSubmitting} + > + + + + +