From 9d3206f229170b969deecca080357141b33607f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 30 Aug 2022 22:26:42 +0200 Subject: [PATCH 01/32] Allow users to create events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/events.ts | 268 ++++++++++++++++++ app/soapbox/actions/interactions.ts | 1 - .../components/autosuggest-location.tsx | 41 +++ app/soapbox/components/autosuggest_input.tsx | 7 +- .../components/hover-status-wrapper.tsx | 2 +- app/soapbox/components/location-search.tsx | 110 +++++++ app/soapbox/components/polls/poll-footer.tsx | 1 - .../components/ui/textarea/textarea.tsx | 2 +- .../compose/components/schedule_form.tsx | 2 +- .../features/compose/components/upload.tsx | 2 +- .../containers/compose_form_container.js | 4 +- .../steps/suggested-accounts-step.tsx | 1 - app/soapbox/features/status/index.tsx | 2 +- .../features/ui/components/modal_root.js | 2 + .../create-event-modal/create-event-modal.tsx | 223 +++++++++++++++ .../create-event-modal/upload-button.tsx | 59 ++++ .../features/ui/util/async-components.ts | 4 + app/soapbox/normalizers/index.ts | 1 + app/soapbox/normalizers/location.ts | 35 +++ app/soapbox/queries/suggestions.ts | 1 - app/soapbox/reducers/admin.ts | 2 +- app/soapbox/reducers/create_event.ts | 86 ++++++ app/soapbox/reducers/index.ts | 4 + app/soapbox/reducers/locations.ts | 28 ++ app/soapbox/reducers/trending_statuses.ts | 2 +- app/soapbox/types/entities.ts | 3 + package.json | 2 +- yarn.lock | 8 +- 28 files changed, 884 insertions(+), 19 deletions(-) create mode 100644 app/soapbox/actions/events.ts create mode 100644 app/soapbox/components/autosuggest-location.tsx create mode 100644 app/soapbox/components/location-search.tsx create mode 100644 app/soapbox/features/ui/components/modals/create-event-modal/create-event-modal.tsx create mode 100644 app/soapbox/features/ui/components/modals/create-event-modal/upload-button.tsx create mode 100644 app/soapbox/normalizers/location.ts create mode 100644 app/soapbox/reducers/create_event.ts create mode 100644 app/soapbox/reducers/locations.ts diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts new file mode 100644 index 000000000..f9417a6c0 --- /dev/null +++ b/app/soapbox/actions/events.ts @@ -0,0 +1,268 @@ +import { defineMessages, IntlShape } from 'react-intl'; + +import api from 'soapbox/api'; +import { formatBytes } from 'soapbox/utils/media'; +import resizeImage from 'soapbox/utils/resize_image'; + +import { importFetchedStatus } from './importer'; +import { fetchMedia, uploadMedia } from './media'; +import { closeModal } from './modals'; +import snackbar from './snackbar'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST'; +const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS'; +const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL'; + +const CREATE_EVENT_NAME_CHANGE = 'CREATE_EVENT_NAME_CHANGE'; +const CREATE_EVENT_DESCRIPTION_CHANGE = 'CREATE_EVENT_DESCRIPTION_CHANGE'; +const CREATE_EVENT_START_TIME_CHANGE = 'CREATE_EVENT_START_TIME_CHANGE'; +const CREATE_EVENT_HAS_END_TIME_CHANGE = 'CREATE_EVENT_HAS_END_TIME_CHANGE'; +const CREATE_EVENT_END_TIME_CHANGE = 'CREATE_EVENT_END_TIME_CHANGE'; +const CREATE_EVENT_APPROVAL_REQUIRED_CHANGE = 'CREATE_EVENT_APPROVAL_REQUIRED_CHANGE'; +const CREATE_EVENT_LOCATION_CHANGE = 'CREATE_EVENT_LOCATION_CHANGE'; + +const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST'; +const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS'; +const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS'; +const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL'; +const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO'; + +const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST'; +const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS'; +const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_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' }, + view: { id: 'snackbar.view', defaultMessage: 'View' }, +}); + +const locationSearch = (query: string, signal?: AbortSignal) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: LOCATION_SEARCH_REQUEST, query }); + return api(getState).get('/api/v1/pleroma/search/location', { params: { q: query }, signal }).then(({ data: locations }) => { + dispatch({ type: LOCATION_SEARCH_SUCCESS, locations }); + return locations; + }).catch(error => { + dispatch({ type: LOCATION_SEARCH_FAIL }); + throw error; + }); + }; + +const changeCreateEventName = (value: string) => ({ + type: CREATE_EVENT_NAME_CHANGE, + value, +}); + +const changeCreateEventDescription = (value: string) => ({ + type: CREATE_EVENT_DESCRIPTION_CHANGE, + value, +}); + +const changeCreateEventStartTime = (value: Date) => ({ + type: CREATE_EVENT_START_TIME_CHANGE, + value, +}); + +const changeCreateEventEndTime = (value: Date) => ({ + type: CREATE_EVENT_END_TIME_CHANGE, + value, +}); + +const changeCreateEventHasEndTime = (value: boolean) => ({ + type: CREATE_EVENT_HAS_END_TIME_CHANGE, + value, +}); + +const changeCreateEventApprovalRequired = (value: boolean) => ({ + type: CREATE_EVENT_APPROVAL_REQUIRED_CHANGE, + value, +}); + +const changeCreateEventLocation = (value: string | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + let location = null; + + if (value) { + location = getState().locations.get(value); + } + + dispatch({ + type: CREATE_EVENT_LOCATION_CHANGE, + value: location, + }); + }; + +const uploadEventBanner = (file: File, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; + + let progress = 0; + + dispatch(uploadEventBannerRequest()); + + if (maxImageSize && (file.size > maxImageSize)) { + const limit = formatBytes(maxImageSize); + const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); + dispatch(snackbar.error(message)); + dispatch(uploadEventBannerFail(true)); + return; + } + + resizeImage(file).then(file => { + const data = new FormData(); + data.append('file', file); + // Account for disparity in size of original image and resized data + + const onUploadProgress = ({ loaded }: any) => { + progress = loaded; + dispatch(uploadEventBannerProgress(progress)); + }; + + return dispatch(uploadMedia(data, onUploadProgress)) + .then(({ status, data }) => { + // If server-side processing of the media attachment has not completed yet, + // poll the server until it is, before showing the media attachment as uploaded + if (status === 200) { + dispatch(uploadEventBannerSuccess(data, file)); + } else if (status === 202) { + const poll = () => { + dispatch(fetchMedia(data.id)).then(({ status, data }) => { + if (status === 200) { + dispatch(uploadEventBannerSuccess(data, file)); + } else if (status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => dispatch(uploadEventBannerFail(error))); + }; + + poll(); + } + }); + }).catch(error => dispatch(uploadEventBannerFail(error))); + }; + +const uploadEventBannerRequest = () => ({ + type: EVENT_BANNER_UPLOAD_REQUEST, + skipLoading: true, +}); + +const uploadEventBannerProgress = (loaded: number) => ({ + type: EVENT_BANNER_UPLOAD_PROGRESS, + loaded: loaded, +}); + +const uploadEventBannerSuccess = (media: APIEntity, file: File) => ({ + type: EVENT_BANNER_UPLOAD_SUCCESS, + media: media, + file, + skipLoading: true, +}); + +const uploadEventBannerFail = (error: AxiosError | true) => ({ + type: EVENT_BANNER_UPLOAD_FAIL, + error: error, + skipLoading: true, +}); + +const undoUploadEventBanner = () => ({ + type: EVENT_BANNER_UPLOAD_UNDO, +}); + +const submitEvent = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const name = state.create_event.name; + const status = state.create_event.status; + const banner = state.create_event.banner; + const startTime = state.create_event.start_time; + const endTime = state.create_event.end_time; + const joinMode = state.create_event.approval_required ? 'restricted' : 'free'; + const location = state.create_event.location; + + if (!status || !status.length) { + return; + } + + dispatch(submitEventRequest()); + + const idempotencyKey = state.compose.idempotencyKey; + + const params: Record = { + name, + status, + start_time: startTime, + join_mode: joinMode, + }; + + if (endTime) params.end_time = endTime; + if (banner) params.banner_id = banner.id; + if (location) params.location_id = location.origin_id; + + return api(getState).post('/api/v1/pleroma/events', params).then(({ data }) => { + dispatch(closeModal('CREATE_EVENT')); + dispatch(importFetchedStatus(data, idempotencyKey)); + dispatch(submitEventSuccess(data)); + dispatch(snackbar.success(messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); + }).catch(function(error) { + dispatch(submitEventFail(error)); + }); + }; + +const submitEventRequest = () => ({ + type: EVENT_SUBMIT_REQUEST, +}); + +const submitEventSuccess = (status: APIEntity) => ({ + type: EVENT_SUBMIT_SUCCESS, + status: status, +}); + +const submitEventFail = (error: AxiosError) => ({ + type: EVENT_SUBMIT_FAIL, + error: error, +}); + +export { + LOCATION_SEARCH_REQUEST, + LOCATION_SEARCH_SUCCESS, + LOCATION_SEARCH_FAIL, + CREATE_EVENT_NAME_CHANGE, + CREATE_EVENT_DESCRIPTION_CHANGE, + CREATE_EVENT_START_TIME_CHANGE, + CREATE_EVENT_END_TIME_CHANGE, + CREATE_EVENT_HAS_END_TIME_CHANGE, + CREATE_EVENT_APPROVAL_REQUIRED_CHANGE, + CREATE_EVENT_LOCATION_CHANGE, + EVENT_BANNER_UPLOAD_REQUEST, + EVENT_BANNER_UPLOAD_PROGRESS, + EVENT_BANNER_UPLOAD_SUCCESS, + EVENT_BANNER_UPLOAD_FAIL, + EVENT_BANNER_UPLOAD_UNDO, + EVENT_SUBMIT_REQUEST, + EVENT_SUBMIT_SUCCESS, + EVENT_SUBMIT_FAIL, + locationSearch, + changeCreateEventName, + changeCreateEventDescription, + changeCreateEventStartTime, + changeCreateEventEndTime, + changeCreateEventHasEndTime, + changeCreateEventApprovalRequired, + changeCreateEventLocation, + uploadEventBanner, + uploadEventBannerRequest, + uploadEventBannerProgress, + uploadEventBannerSuccess, + uploadEventBannerFail, + undoUploadEventBanner, + submitEvent, + submitEventRequest, + submitEventSuccess, + submitEventFail, +}; diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 1a90a259f..cb23f3dae 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -177,7 +177,6 @@ const toggleFavourite = (status: StatusEntity) => } }; - const favouriteRequest = (status: StatusEntity) => ({ type: FAVOURITE_REQUEST, status: status, diff --git a/app/soapbox/components/autosuggest-location.tsx b/app/soapbox/components/autosuggest-location.tsx new file mode 100644 index 000000000..6b6eee434 --- /dev/null +++ b/app/soapbox/components/autosuggest-location.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { useAppSelector } from 'soapbox/hooks'; + +import { HStack, Icon, Stack, Text } from './ui'; + +const buildingCommunityIcon = require('@tabler/icons/building-community.svg'); +const homeIcon = require('@tabler/icons/home-2.svg'); +const mapPinIcon = require('@tabler/icons/map-pin.svg'); +const roadIcon = require('@tabler/icons/road.svg'); + +export const ADDRESS_ICONS: Record = { + house: homeIcon, + street: roadIcon, + secondary: roadIcon, + zone: buildingCommunityIcon, + city: buildingCommunityIcon, + administrative: buildingCommunityIcon, +}; + +interface IAutosuggestLocation { + id: string, +} + +const AutosuggestLocation: React.FC = ({ id }) => { + const location = useAppSelector((state) => state.locations.get(id)); + + if (!location) return null; + + return ( + + + + {location.description} + {[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')} + + + ); +}; + +export default AutosuggestLocation; diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx index 54f126a23..fcc9cdc92 100644 --- a/app/soapbox/components/autosuggest_input.tsx +++ b/app/soapbox/components/autosuggest_input.tsx @@ -59,6 +59,7 @@ interface IAutosuggestInput extends Pick, maxLength?: number, menu?: Menu, resultsPosition: string, + renderSuggestion?: React.FC<{ id: string }>, } export default class AutosuggestInput extends ImmutablePureComponent { @@ -200,7 +201,11 @@ export default class AutosuggestInput extends ImmutablePureComponent; + key = suggestion; + } else if (typeof suggestion === 'object') { inner = ; key = suggestion.id; } else if (suggestion[0] === '#') { diff --git a/app/soapbox/components/hover-status-wrapper.tsx b/app/soapbox/components/hover-status-wrapper.tsx index 6860762e7..7dbe61c26 100644 --- a/app/soapbox/components/hover-status-wrapper.tsx +++ b/app/soapbox/components/hover-status-wrapper.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React, { useRef } from 'react'; import { useDispatch } from 'react-redux'; diff --git a/app/soapbox/components/location-search.tsx b/app/soapbox/components/location-search.tsx new file mode 100644 index 000000000..915c2770a --- /dev/null +++ b/app/soapbox/components/location-search.tsx @@ -0,0 +1,110 @@ +import classNames from 'classnames'; +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import throttle from 'lodash/throttle'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { locationSearch } from 'soapbox/actions/events'; +import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest_input'; +import Icon from 'soapbox/components/icon'; +import { useAppDispatch } from 'soapbox/hooks'; + +import AutosuggestLocation from './autosuggest-location'; + +const noOp = () => {}; + +const messages = defineMessages({ + placeholder: { id: 'location_search.placeholder', defaultMessage: 'Find an address' }, +}); + +interface ILocationSearch { + onSelected: (locationId: string) => void, +} + +const LocationSearch: React.FC = ({ onSelected }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const [locationIds, setLocationIds] = useState(ImmutableOrderedSet()); + const controller = useRef(new AbortController()); + + const [value, setValue] = useState(''); + + const isEmpty = (): boolean => { + return !(value.length > 0); + }; + + const handleChange: React.ChangeEventHandler = ({ target }) => { + refreshCancelToken(); + handleLocationSearch(target.value); + setValue(target.value); + }; + + const handleSelected = (_tokenStart: number, _lastToken: string | null, suggestion: AutoSuggestion) => { + if (typeof suggestion === 'string') { + onSelected(suggestion); + } + }; + + const handleClear: React.MouseEventHandler = e => { + e.preventDefault(); + + if (!isEmpty()) { + setValue(''); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + if (e.key === 'Escape') { + document.querySelector('.ui')?.parentElement?.focus(); + } + }; + + const refreshCancelToken = () => { + controller.current.abort(); + controller.current = new AbortController(); + }; + + const clearResults = () => { + setLocationIds(ImmutableOrderedSet()); + }; + + const handleLocationSearch = useCallback(throttle(q => { + dispatch(locationSearch(q, controller.current.signal)) + .then((locations: { origin_id: string }[]) => { + const locationIds = locations.map(location => location.origin_id); + setLocationIds(ImmutableOrderedSet(locationIds)); + }) + .catch(noOp); + + }, 900, { leading: true, trailing: true }), []); + + useEffect(() => { + if (value === '') { + clearResults(); + } + }, [value]); + + return ( +
+ +
+ + +
+
+ ); +}; + +export default LocationSearch; diff --git a/app/soapbox/components/polls/poll-footer.tsx b/app/soapbox/components/polls/poll-footer.tsx index 366f34d1c..a663b0e13 100644 --- a/app/soapbox/components/polls/poll-footer.tsx +++ b/app/soapbox/components/polls/poll-footer.tsx @@ -45,7 +45,6 @@ const PollFooter: React.FC = ({ poll, showResults, selected }): JSX votesCount = ; } - return ( {(!showResults && poll?.multiple) && ( diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index 5ba1a523c..be13a6657 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -28,7 +28,7 @@ const Textarea = React.forwardRef( {...props} ref={ref} className={classNames({ - 'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': + 'bg-white dark:bg-gray-900 shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': true, 'font-mono': isCodeEditor, 'text-red-600 border-red-600': hasError, diff --git a/app/soapbox/features/compose/components/schedule_form.tsx b/app/soapbox/features/compose/components/schedule_form.tsx index 32137a71f..72afa86de 100644 --- a/app/soapbox/features/compose/components/schedule_form.tsx +++ b/app/soapbox/features/compose/components/schedule_form.tsx @@ -11,7 +11,7 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import { DatePicker } from 'soapbox/features/ui/util/async-components'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; -const isCurrentOrFutureDate = (date: Date) => { +export const isCurrentOrFutureDate = (date: Date) => { return date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0); }; diff --git a/app/soapbox/features/compose/components/upload.tsx b/app/soapbox/features/compose/components/upload.tsx index 5c9788575..80dcf3a84 100644 --- a/app/soapbox/features/compose/components/upload.tsx +++ b/app/soapbox/features/compose/components/upload.tsx @@ -148,7 +148,7 @@ const Upload: React.FC = (props) => { {({ scale }) => (
({ dispatch(selectComposeSuggestion(position, token, suggestion, path)); }, - onChangeSpoilerText(checked) { - dispatch(changeComposeSpoilerText(checked)); + onChangeSpoilerText(value) { + dispatch(changeComposeSpoilerText(value)); }, onPaste(files) { diff --git a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx index 4df00061a..8db08d1a4 100644 --- a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx +++ b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx @@ -10,7 +10,6 @@ import useOnboardingSuggestions from 'soapbox/queries/suggestions'; const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => { const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions(); - const handleLoadMore = debounce(() => { if (isFetching) { return null; diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 1f02961d8..935d8b07a 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; import { defineMessages, useIntl } from 'react-intl'; diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index f7b9b007c..050d705b6 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -32,6 +32,7 @@ import { CompareHistoryModal, VerifySmsModal, FamiliarFollowersModal, + CreateEventModal, } from 'soapbox/features/ui/util/async-components'; import BundleContainer from '../containers/bundle_container'; @@ -69,6 +70,7 @@ const MODAL_COMPONENTS = { 'COMPARE_HISTORY': CompareHistoryModal, 'VERIFY_SMS': VerifySmsModal, 'FAMILIAR_FOLLOWERS': FamiliarFollowersModal, + 'CREATE_EVENT': CreateEventModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/soapbox/features/ui/components/modals/create-event-modal/create-event-modal.tsx b/app/soapbox/features/ui/components/modals/create-event-modal/create-event-modal.tsx new file mode 100644 index 000000000..3e4f0f86e --- /dev/null +++ b/app/soapbox/features/ui/components/modals/create-event-modal/create-event-modal.tsx @@ -0,0 +1,223 @@ +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import Toggle from 'react-toggle'; + +import { + changeCreateEventApprovalRequired, + changeCreateEventDescription, + changeCreateEventEndTime, + changeCreateEventHasEndTime, + changeCreateEventName, + changeCreateEventStartTime, + changeCreateEventLocation, + uploadEventBanner, + undoUploadEventBanner, + submitEvent, +} from 'soapbox/actions/events'; +import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location'; +import LocationSearch from 'soapbox/components/location-search'; +import { Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Stack, Text, Textarea } from 'soapbox/components/ui'; +import { isCurrentOrFutureDate } from 'soapbox/features/compose/components/schedule_form'; +import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; +import { DatePicker } from 'soapbox/features/ui/util/async-components'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import UploadButton from './upload-button'; + +const messages = defineMessages({ + eventNamePlaceholder: { id: 'create_event.fields.name_placeholder', defaultMessage: 'Name' }, + eventDescriptionPlaceholder: { id: 'create_event.fields.description_placeholder', defaultMessage: 'Description' }, + eventStartTimePlaceholder: { id: 'create_event.fields.start_time_placeholder', defaultMessage: 'Event begins on…' }, + eventEndTimePlaceholder: { id: 'create_event.fields.end_time_placeholder', defaultMessage: 'Event ends on…' }, + resetLocation: { id: 'create_event.reset_location', defaultMessage: 'Reset location' }, +}); + +interface ICreateEventModal { + onClose: (type?: string) => void, +} + +const CreateEventModal: React.FC = ({ onClose }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const banner = useAppSelector((state) => state.create_event.banner); + const isUploading = useAppSelector((state) => state.create_event.is_uploading); + + const name = useAppSelector((state) => state.create_event.name); + const description = useAppSelector((state) => state.create_event.status); + const startTime = useAppSelector((state) => state.create_event.start_time); + const endTime = useAppSelector((state) => state.create_event.end_time); + const approvalRequired = useAppSelector((state) => state.create_event.approval_required); + const location = useAppSelector((state) => state.create_event.location); + + const isSubmitting = useAppSelector((state) => state.create_event.is_submitting); + + const onChangeName: React.ChangeEventHandler = ({ target }) => { + dispatch(changeCreateEventName(target.value)); + }; + + const onChangeDescription: React.ChangeEventHandler = ({ target }) => { + dispatch(changeCreateEventDescription(target.value)); + }; + + const onChangeStartTime = (date: Date) => { + dispatch(changeCreateEventStartTime(date)); + }; + + const onChangeEndTime = (date: Date) => { + dispatch(changeCreateEventEndTime(date)); + }; + + const onChangeHasEndTime: React.ChangeEventHandler = ({ target }) => { + dispatch(changeCreateEventHasEndTime(target.checked)); + }; + + const onChangeApprovalRequired: React.ChangeEventHandler = ({ target }) => { + dispatch(changeCreateEventApprovalRequired(target.checked)); + }; + + const onChangeLocation = (value: string | null) => { + dispatch(changeCreateEventLocation(value)); + }; + + const onClickClose = () => { + onClose('CREATE_EVENT'); + }; + + const handleFiles = (files: FileList) => { + dispatch(uploadEventBanner(files[0], intl)); + }; + + const handleClearBanner = () => { + dispatch(undoUploadEventBanner()); + }; + + const handleSubmit = () => { + dispatch(submitEvent()); + }; + + const renderLocation = () => location && ( + + + + {location.description} + {[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')} + + onChangeLocation(null)} /> + + ); + + return ( + } + confirmationAction={handleSubmit} + confirmationText={} + confirmationDisabled={isSubmitting} + onClose={onClickClose} + > +
+ } + > +
+ {banner ? ( + <> + + + + ) : ( + + )} + +
+
+ } + > + + + } + hintText={} + > +