Allow users to create events

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-08-30 22:26:42 +02:00
parent 7d5a8ecf6f
commit 9d3206f229
28 changed files with 884 additions and 19 deletions

View file

@ -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<string, any> = {
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,
};

View file

@ -177,7 +177,6 @@ const toggleFavourite = (status: StatusEntity) =>
}
};
const favouriteRequest = (status: StatusEntity) => ({
type: FAVOURITE_REQUEST,
status: status,

View file

@ -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<string, string> = {
house: homeIcon,
street: roadIcon,
secondary: roadIcon,
zone: buildingCommunityIcon,
city: buildingCommunityIcon,
administrative: buildingCommunityIcon,
};
interface IAutosuggestLocation {
id: string,
}
const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => {
const location = useAppSelector((state) => state.locations.get(id));
if (!location) return null;
return (
<HStack alignItems='center' space={2}>
<Icon src={ADDRESS_ICONS[location.type] || mapPinIcon} />
<Stack>
<Text>{location.description}</Text>
<Text size='xs' theme='muted'>{[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')}</Text>
</Stack>
</HStack>
);
};
export default AutosuggestLocation;

View file

@ -59,6 +59,7 @@ interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>,
maxLength?: number,
menu?: Menu,
resultsPosition: string,
renderSuggestion?: React.FC<{ id: string }>,
}
export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> {
@ -200,7 +201,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
if (this.props.renderSuggestion && typeof suggestion === 'string') {
const RenderSuggestion = this.props.renderSuggestion;
inner = <RenderSuggestion id={suggestion} />;
key = suggestion;
} else if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {

View file

@ -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';

View file

@ -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<ILocationSearch> = ({ onSelected }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [locationIds, setLocationIds] = useState(ImmutableOrderedSet<string>());
const controller = useRef(new AbortController());
const [value, setValue] = useState('');
const isEmpty = (): boolean => {
return !(value.length > 0);
};
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ 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 (
<div className='search'>
<AutosuggestInput
className='rounded-full'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={handleChange}
suggestions={locationIds.toList()}
onSuggestionsFetchRequested={noOp}
onSuggestionsClearRequested={noOp}
onSuggestionSelected={handleSelected}
searchTokens={[]}
onKeyDown={handleKeyDown}
renderSuggestion={AutosuggestLocation}
/>
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
<Icon src={require('@tabler/icons/search.svg')} className={classNames('svg-icon--search', { active: isEmpty() })} />
<Icon src={require('@tabler/icons/backspace.svg')} className={classNames('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} />
</div>
</div>
);
};
export default LocationSearch;

View file

@ -45,7 +45,6 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
}
return (
<Stack space={4} data-testid='poll-footer'>
{(!showResults && poll?.multiple) && (

View file

@ -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,

View file

@ -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);
};

View file

@ -148,7 +148,7 @@ const Upload: React.FC<IUpload> = (props) => {
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div
className={classNames('compose-form__upload-thumbnail', `${mediaType}`)}
className={classNames('compose-form__upload-thumbnail', mediaType)}
style={{
transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${props.media.get('preview_url')})` : undefined,

View file

@ -63,8 +63,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(selectComposeSuggestion(position, token, suggestion, path));
},
onChangeSpoilerText(checked) {
dispatch(changeComposeSpoilerText(checked));
onChangeSpoilerText(value) {
dispatch(changeComposeSpoilerText(value));
},
onPaste(files) {

View file

@ -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;

View file

@ -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';

View file

@ -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 {

View file

@ -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<ICreateEventModal> = ({ 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<HTMLInputElement> = ({ target }) => {
dispatch(changeCreateEventName(target.value));
};
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
dispatch(changeCreateEventDescription(target.value));
};
const onChangeStartTime = (date: Date) => {
dispatch(changeCreateEventStartTime(date));
};
const onChangeEndTime = (date: Date) => {
dispatch(changeCreateEventEndTime(date));
};
const onChangeHasEndTime: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
dispatch(changeCreateEventHasEndTime(target.checked));
};
const onChangeApprovalRequired: React.ChangeEventHandler<HTMLInputElement> = ({ 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 && (
<HStack className='h-[38px] text-gray-700 dark:text-gray-500' alignItems='center' space={2}>
<Icon src={ADDRESS_ICONS[location.type] || require('@tabler/icons/map-pin.svg')} />
<Stack className='flex-grow'>
<Text>{location.description}</Text>
<Text theme='muted' size='xs'>{[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')}</Text>
</Stack>
<IconButton title={intl.formatMessage(messages.resetLocation)} src={require('@tabler/icons/x.svg')} onClick={() => onChangeLocation(null)} />
</HStack>
);
return (
<Modal
title={<FormattedMessage id='navigation_bar.create_event' defaultMessage='Create new event' />}
confirmationAction={handleSubmit}
confirmationText={<FormattedMessage id='create_event.create' defaultMessage='Create' />}
confirmationDisabled={isSubmitting}
onClose={onClickClose}
>
<Form>
<FormGroup
labelText={<FormattedMessage id='create_event.fields.banner_label' defaultMessage='Event banner' />}
>
<div className='flex items-center justify-center bg-gray-200 dark:bg-gray-900/50 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
{banner ? (
<>
<img className='h-full w-full object-cover' src={banner.url} alt='' />
<IconButton className='absolute top-2 right-2' src={require('@tabler/icons/x.svg')} onClick={handleClearBanner} />
</>
) : (
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
)}
</div>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='create_event.fields.name_label' defaultMessage='Event name' />}
>
<Input
type='text'
placeholder={intl.formatMessage(messages.eventNamePlaceholder)}
value={name}
onChange={onChangeName}
/>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='create_event.fields.description_label' defaultMessage='Event description' />}
hintText={<FormattedMessage id='create_event.fields.description_hint' defaultMessage='Markdown syntax is supported' />}
>
<Textarea
autoComplete='off'
placeholder={intl.formatMessage(messages.eventDescriptionPlaceholder)}
value={description}
onChange={onChangeDescription}
/>
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='create_event.fields.location_label' defaultMessage='Event location' />}
>
{location ? renderLocation() : (
<LocationSearch
onSelected={onChangeLocation}
/>
)}
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='create_event.fields.start_time_label' defaultMessage='Event start date' />}
>
<BundleContainer fetchComponent={DatePicker}>
{Component => (<Component
showTimeSelect
dateFormat='MMMM d, yyyy h:mm aa'
timeIntervals={15}
wrapperClassName='react-datepicker-wrapper'
placeholderText={intl.formatMessage(messages.eventStartTimePlaceholder)}
filterDate={isCurrentOrFutureDate}
selected={startTime}
onChange={onChangeStartTime}
/>)}
</BundleContainer>
</FormGroup>
<HStack alignItems='center' space={2}>
<Toggle
icons={false}
checked={!!endTime}
onChange={onChangeHasEndTime}
/>
<Text tag='span' theme='muted'>
<FormattedMessage id='create_event.fields.has_end_time' defaultMessage='The event has end date' />
</Text>
</HStack>
{endTime && (
<FormGroup
labelText={<FormattedMessage id='create_event.fields.end_time_label' defaultMessage='Event end date' />}
>
<BundleContainer fetchComponent={DatePicker}>
{Component => (<Component
showTimeSelect
dateFormat='MMMM d, yyyy h:mm aa'
timeIntervals={15}
wrapperClassName='react-datepicker-wrapper'
placeholderText={intl.formatMessage(messages.eventEndTimePlaceholder)}
filterDate={isCurrentOrFutureDate}
selected={endTime}
onChange={onChangeEndTime}
/>)}
</BundleContainer>
</FormGroup>
)}
<HStack alignItems='center' space={2}>
<Toggle
icons={false}
checked={approvalRequired}
onChange={onChangeApprovalRequired}
/>
<Text tag='span' theme='muted'>
<FormattedMessage id='create_event.fields.approval_required' defaultMessage='I want to approve participation requests manually' />
</Text>
</HStack>
</Form>
</Modal>
);
};
export default CreateEventModal;

View file

@ -0,0 +1,59 @@
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { IconButton } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
upload: { id: 'create_event.upload_banner', defaultMessage: 'Upload event banner' },
});
interface IUploadButton {
disabled?: boolean,
onSelectFile: (files: FileList) => void,
}
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
const intl = useIntl();
const fileElement = useRef<HTMLInputElement>(null);
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) {
onSelectFile(e.target.files);
}
};
const handleClick = () => {
fileElement.current?.click();
};
return (
<div>
<IconButton
src={require('@tabler/icons/photo-plus.svg')}
className='h-8 w-8 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
title={intl.formatMessage(messages.upload)}
disabled={disabled}
onClick={handleClick}
/>
<label>
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
<input
ref={fileElement}
type='file'
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
onChange={handleChange}
disabled={disabled}
className='hidden'
/>
</label>
</div>
);
};
export default UploadButton;

View file

@ -525,3 +525,7 @@ export function FamiliarFollowersModal() {
export function AnnouncementsPanel() {
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
}
export function CreateEventModal() {
return import(/* webpackChunkName: "features/create_event_modal" */'../components/modals/create-event-modal/create-event-modal');
}

View file

@ -12,6 +12,7 @@ export { FilterRecord, normalizeFilter } from './filter';
export { HistoryRecord, normalizeHistory } from './history';
export { InstanceRecord, normalizeInstance } from './instance';
export { ListRecord, normalizeList } from './list';
export { LocationRecord, normalizeLocation } from './location';
export { MentionRecord, normalizeMention } from './mention';
export { NotificationRecord, normalizeNotification } from './notification';
export { PollRecord, PollOptionRecord, normalizePoll } from './poll';

View file

@ -0,0 +1,35 @@
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
export const GeographicLocationRecord = ImmutableRecord({
coordinates: null as [number, number] | null,
srid: '',
});
export const LocationRecord = ImmutableRecord({
url: '',
description: '',
country: '',
locality: '',
region: '',
postal_code: '',
street: '',
origin_id: '',
origin_provider: '',
type: '',
timezone: '',
geom: null as ReturnType<typeof GeographicLocationRecord> | null,
});
const normalizeGeographicLocation = (location: ImmutableMap<string, any>) => {
if (location.get('geom')) {
return location.set('geom', GeographicLocationRecord(location.get('geom')));
}
return location;
};
export const normalizeLocation = (location: Record<string, any>) => {
return LocationRecord(ImmutableMap(fromJS(location)).withMutations((location: ImmutableMap<string, any>) => {
normalizeGeographicLocation(location);
}));
};

View file

@ -35,7 +35,6 @@ type Suggestion = {
account: Account
}
export default function useOnboardingSuggestions() {
const api = useApi();
const dispatch = useAppDispatch();

View file

@ -21,10 +21,10 @@ import {
ADMIN_USERS_APPROVE_SUCCESS,
} from 'soapbox/actions/admin';
import { normalizeAdminReport, normalizeAdminAccount } from 'soapbox/normalizers';
import { APIEntity } from 'soapbox/types/entities';
import { normalizeId } from 'soapbox/utils/normalizers';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
import type { Config } from 'soapbox/utils/config_db';
const ReducerRecord = ImmutableRecord({

View file

@ -0,0 +1,86 @@
import { fromJS, Record as ImmutableRecord } from 'immutable';
import { AnyAction } from 'redux';
import {
CREATE_EVENT_APPROVAL_REQUIRED_CHANGE,
CREATE_EVENT_DESCRIPTION_CHANGE,
CREATE_EVENT_END_TIME_CHANGE,
CREATE_EVENT_HAS_END_TIME_CHANGE,
CREATE_EVENT_LOCATION_CHANGE,
CREATE_EVENT_NAME_CHANGE,
CREATE_EVENT_START_TIME_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,
} from 'soapbox/actions/events';
import { normalizeAttachment } from 'soapbox/normalizers';
import type {
Attachment as AttachmentEntity,
Location as LocationEntity,
} from 'soapbox/types/entities';
export const ReducerRecord = ImmutableRecord({
name: '',
status: '',
location: null as LocationEntity | null,
start_time: new Date(),
end_time: null as Date | null,
approval_required: false,
banner: null as AttachmentEntity | null,
progress: 0,
is_uploading: false,
is_submitting: false,
});
type State = ReturnType<typeof ReducerRecord>;
const setHasEndTime = (state: State) => {
const endTime = new Date(state.start_time);
endTime.setHours(endTime.getHours() + 2);
return state.set('end_time', endTime);
};
export default function create_event(state = ReducerRecord(), action: AnyAction): State {
switch (action.type) {
case CREATE_EVENT_NAME_CHANGE:
return state.set('name', action.value);
case CREATE_EVENT_DESCRIPTION_CHANGE:
return state.set('status', action.value);
case CREATE_EVENT_START_TIME_CHANGE:
return state.set('start_time', action.value);
case CREATE_EVENT_END_TIME_CHANGE:
return state.set('end_time', action.value);
case CREATE_EVENT_HAS_END_TIME_CHANGE:
if (action.value) return setHasEndTime(state);
return state.set('end_time', null);
case CREATE_EVENT_APPROVAL_REQUIRED_CHANGE:
return state.set('approval_required', action.value);
case CREATE_EVENT_LOCATION_CHANGE:
return state.set('location', action.value);
case EVENT_BANNER_UPLOAD_REQUEST:
return state.set('is_uploading', true);
case EVENT_BANNER_UPLOAD_SUCCESS:
return state.set('banner', normalizeAttachment(fromJS(action.media)));
case EVENT_BANNER_UPLOAD_FAIL:
return state.set('is_uploading', false);
case EVENT_BANNER_UPLOAD_UNDO:
return state.set('banner', null);
case EVENT_BANNER_UPLOAD_PROGRESS:
return state.set('progress', action.loaded * 100);
case EVENT_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case EVENT_SUBMIT_SUCCESS:
case EVENT_SUBMIT_FAIL:
return state.set('is_submitting', false);
default:
return state;
}
}

View file

@ -21,6 +21,7 @@ import chats from './chats';
import compose from './compose';
import contexts from './contexts';
import conversations from './conversations';
import create_event from './create_event';
import custom_emojis from './custom_emojis';
import domain_lists from './domain_lists';
import dropdown_menu from './dropdown_menu';
@ -34,6 +35,7 @@ import instance from './instance';
import listAdder from './list_adder';
import listEditor from './list_editor';
import lists from './lists';
import locations from './locations';
import me from './me';
import meta from './meta';
import modals from './modals';
@ -90,6 +92,7 @@ const reducers = {
lists,
listEditor,
listAdder,
locations,
filters,
conversations,
suggestions,
@ -124,6 +127,7 @@ const reducers = {
rules,
history,
announcements,
create_event,
};
// Build a default state from all reducers: it has the key and `undefined`

View file

@ -0,0 +1,28 @@
import { Map as ImmutableMap } from 'immutable';
import { AnyAction } from 'redux';
import { LOCATION_SEARCH_SUCCESS } from 'soapbox/actions/events';
import { normalizeLocation } from 'soapbox/normalizers/location';
import type { APIEntity } from 'soapbox/types/entities';
type LocationRecord = ReturnType<typeof normalizeLocation>;
type State = ImmutableMap<any, LocationRecord>;
const initialState: State = ImmutableMap();
const normalizeLocations = (state: State, locations: APIEntity[]) => {
return locations.reduce(
(state: State, location: APIEntity) => state.set(location.origin_id, normalizeLocation(location)),
state,
);
};
export default function accounts(state: State = initialState, action: AnyAction): State {
switch (action.type) {
case LOCATION_SEARCH_SUCCESS:
return normalizeLocations(state, action.locations);
default:
return state;
}
}

View file

@ -4,9 +4,9 @@ import {
TRENDING_STATUSES_FETCH_REQUEST,
TRENDING_STATUSES_FETCH_SUCCESS,
} from 'soapbox/actions/trending_statuses';
import { APIEntity } from 'soapbox/types/entities';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
const ReducerRecord = ImmutableRecord({
items: ImmutableOrderedSet<string>(),

View file

@ -14,6 +14,7 @@ import {
HistoryRecord,
InstanceRecord,
ListRecord,
LocationRecord,
MentionRecord,
NotificationRecord,
PollRecord,
@ -40,6 +41,7 @@ type Filter = ReturnType<typeof FilterRecord>;
type History = ReturnType<typeof HistoryRecord>;
type Instance = ReturnType<typeof InstanceRecord>;
type List = ReturnType<typeof ListRecord>;
type Location = ReturnType<typeof LocationRecord>;
type Mention = ReturnType<typeof MentionRecord>;
type Notification = ReturnType<typeof NotificationRecord>;
type Poll = ReturnType<typeof PollRecord>;
@ -80,6 +82,7 @@ export {
History,
Instance,
List,
Location,
Mention,
Notification,
Poll,

View file

@ -67,7 +67,7 @@
"@sentry/browser": "^7.2.0",
"@sentry/react": "^7.2.0",
"@sentry/tracing": "^7.2.0",
"@tabler/icons": "^1.73.0",
"@tabler/icons": "^1.92.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.1",
"@tanstack/react-query": "^4.0.10",

View file

@ -2275,10 +2275,10 @@
remark "^13.0.0"
unist-util-find-all-after "^3.0.2"
"@tabler/icons@^1.73.0":
version "1.73.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.73.0.tgz#26d81858baf41be939504e1f9b4b32835eda6fdb"
integrity sha512-MhAHFzVj79ZWlAIRD++7Mk55PZsdlEdkfkjO3DD257mqj8iJZQRAQtkx2UFJXVs2mMrcOUu1qtj4rlVC8BfnKA==
"@tabler/icons@^1.92.0":
version "1.92.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.92.0.tgz#e068938db61d5f932d317a8fd18acd5c0204496e"
integrity sha512-eZP5jYvNPtZKiWj1Bn2C+zjvGXhQoormXAyE3TH7ihCxXBHMzKF/ZLYhU3rnMC/+NjuLDBifKc6dmwoh7s62vQ==
"@tailwindcss/forms@^0.4.0":
version "0.4.0"