Allow users to create events
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
7d5a8ecf6f
commit
9d3206f229
28 changed files with 884 additions and 19 deletions
268
app/soapbox/actions/events.ts
Normal file
268
app/soapbox/actions/events.ts
Normal 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,
|
||||
};
|
|
@ -177,7 +177,6 @@ const toggleFavourite = (status: StatusEntity) =>
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const favouriteRequest = (status: StatusEntity) => ({
|
||||
type: FAVOURITE_REQUEST,
|
||||
status: status,
|
||||
|
|
41
app/soapbox/components/autosuggest-location.tsx
Normal file
41
app/soapbox/components/autosuggest-location.tsx
Normal 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;
|
|
@ -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] === '#') {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
110
app/soapbox/components/location-search.tsx
Normal file
110
app/soapbox/components/location-search.tsx
Normal 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;
|
|
@ -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) && (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
35
app/soapbox/normalizers/location.ts
Normal file
35
app/soapbox/normalizers/location.ts
Normal 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);
|
||||
}));
|
||||
};
|
|
@ -35,7 +35,6 @@ type Suggestion = {
|
|||
account: Account
|
||||
}
|
||||
|
||||
|
||||
export default function useOnboardingSuggestions() {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
|
|
|
@ -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({
|
||||
|
|
86
app/soapbox/reducers/create_event.ts
Normal file
86
app/soapbox/reducers/create_event.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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`
|
||||
|
|
28
app/soapbox/reducers/locations.ts
Normal file
28
app/soapbox/reducers/locations.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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>(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue