environments/review-chats-g56n7m/deployments/1665
commit
2cedd69f1d
@ -0,0 +1,737 @@
|
||||
import { defineMessages, IntlShape } from 'react-intl';
|
||||
|
||||
import api, { getLinks } from 'soapbox/api';
|
||||
import { formatBytes } from 'soapbox/utils/media';
|
||||
import resizeImage from 'soapbox/utils/resize-image';
|
||||
|
||||
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { fetchMedia, uploadMedia } from './media';
|
||||
import { closeModal, openModal } from './modals';
|
||||
import snackbar from './snackbar';
|
||||
import {
|
||||
STATUS_FETCH_SOURCE_FAIL,
|
||||
STATUS_FETCH_SOURCE_REQUEST,
|
||||
STATUS_FETCH_SOURCE_SUCCESS,
|
||||
} from './statuses';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity, Status as StatusEntity } 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 EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE';
|
||||
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE';
|
||||
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE';
|
||||
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE';
|
||||
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE';
|
||||
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE';
|
||||
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_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 EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST';
|
||||
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS';
|
||||
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL';
|
||||
|
||||
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST';
|
||||
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS';
|
||||
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL';
|
||||
|
||||
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST';
|
||||
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS';
|
||||
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL';
|
||||
|
||||
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST';
|
||||
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS';
|
||||
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL';
|
||||
|
||||
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST';
|
||||
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS';
|
||||
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL';
|
||||
|
||||
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST';
|
||||
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS';
|
||||
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL';
|
||||
|
||||
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST';
|
||||
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS';
|
||||
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL';
|
||||
|
||||
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST';
|
||||
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS';
|
||||
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL';
|
||||
|
||||
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL';
|
||||
|
||||
const EVENT_FORM_SET = 'EVENT_FORM_SET';
|
||||
|
||||
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST';
|
||||
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS';
|
||||
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL';
|
||||
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST';
|
||||
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS';
|
||||
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL';
|
||||
|
||||
const noOp = () => new Promise(f => f(undefined));
|
||||
|
||||
const messages = defineMessages({
|
||||
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
|
||||
success: { id: 'compose_event.submit_success', defaultMessage: 'Your event was created' },
|
||||
editSuccess: { id: 'compose_event.edit_success', defaultMessage: 'Your event was edited' },
|
||||
joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' },
|
||||
joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' },
|
||||
view: { id: 'snackbar.view', defaultMessage: 'View' },
|
||||
authorized: { id: 'compose_event.participation_requests.authorize_success', defaultMessage: 'User accepted' },
|
||||
rejected: { id: 'compose_event.participation_requests.reject_success', defaultMessage: 'User rejected' },
|
||||
});
|
||||
|
||||
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 changeEditEventName = (value: string) => ({
|
||||
type: EDIT_EVENT_NAME_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeEditEventDescription = (value: string) => ({
|
||||
type: EDIT_EVENT_DESCRIPTION_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeEditEventStartTime = (value: Date) => ({
|
||||
type: EDIT_EVENT_START_TIME_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeEditEventEndTime = (value: Date) => ({
|
||||
type: EDIT_EVENT_END_TIME_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeEditEventHasEndTime = (value: boolean) => ({
|
||||
type: EDIT_EVENT_HAS_END_TIME_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeEditEventApprovalRequired = (value: boolean) => ({
|
||||
type: EDIT_EVENT_APPROVAL_REQUIRED_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeEditEventLocation = (value: string | null) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
let location = null;
|
||||
|
||||
if (value) {
|
||||
location = getState().locations.get(value);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: EDIT_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,
|
||||
});
|
||||
|
||||
const uploadEventBannerProgress = (loaded: number) => ({
|
||||
type: EVENT_BANNER_UPLOAD_PROGRESS,
|
||||
loaded,
|
||||
});
|
||||
|
||||
const uploadEventBannerSuccess = (media: APIEntity, file: File) => ({
|
||||
type: EVENT_BANNER_UPLOAD_SUCCESS,
|
||||
media,
|
||||
file,
|
||||
});
|
||||
|
||||
const uploadEventBannerFail = (error: AxiosError | true) => ({
|
||||
type: EVENT_BANNER_UPLOAD_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const undoUploadEventBanner = () => ({
|
||||
type: EVENT_BANNER_UPLOAD_UNDO,
|
||||
});
|
||||
|
||||
const submitEvent = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
|
||||
const id = state.compose_event.id;
|
||||
const name = state.compose_event.name;
|
||||
const status = state.compose_event.status;
|
||||
const banner = state.compose_event.banner;
|
||||
const startTime = state.compose_event.start_time;
|
||||
const endTime = state.compose_event.end_time;
|
||||
const joinMode = state.compose_event.approval_required ? 'restricted' : 'free';
|
||||
const location = state.compose_event.location;
|
||||
|
||||
if (!name || !name.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(submitEventRequest());
|
||||
|
||||
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).request({
|
||||
url: id === null ? '/api/v1/pleroma/events' : `/api/v1/pleroma/events/${id}`,
|
||||
method: id === null ? 'post' : 'put',
|
||||
data: params,
|
||||
}).then(({ data }) => {
|
||||
dispatch(closeModal('COMPOSE_EVENT'));
|
||||
dispatch(importFetchedStatus(data));
|
||||
dispatch(submitEventSuccess(data));
|
||||
dispatch(snackbar.success(id ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/events/${data.id}`));
|
||||
}).catch(function(error) {
|
||||
dispatch(submitEventFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const submitEventRequest = () => ({
|
||||
type: EVENT_SUBMIT_REQUEST,
|
||||
});
|
||||
|
||||
const submitEventSuccess = (status: APIEntity) => ({
|
||||
type: EVENT_SUBMIT_SUCCESS,
|
||||
status,
|
||||
});
|
||||
|
||||
const submitEventFail = (error: AxiosError) => ({
|
||||
type: EVENT_SUBMIT_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const joinEvent = (id: string, participationMessage?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const status = getState().statuses.get(id);
|
||||
|
||||
if (!status || !status.event || status.event.join_state) {
|
||||
return dispatch(noOp);
|
||||
}
|
||||
|
||||
dispatch(joinEventRequest(status));
|
||||
|
||||
return api(getState).post(`/api/v1/pleroma/events/${id}/join`, {
|
||||
participation_message: participationMessage,
|
||||
}).then(({ data }) => {
|
||||
dispatch(importFetchedStatus(data));
|
||||
dispatch(joinEventSuccess(data));
|
||||
dispatch(snackbar.success(
|
||||
data.pleroma.event?.join_state === 'pending' ? messages.joinRequestSuccess : messages.joinSuccess,
|
||||
messages.view,
|
||||
`/@${data.account.acct}/events/${data.id}`,
|
||||
));
|
||||
}).catch(function(error) {
|
||||
dispatch(joinEventFail(error, status, status?.event?.join_state || null));
|
||||
});
|
||||
};
|
||||
|
||||
const joinEventRequest = (status: StatusEntity) => ({
|
||||
type: EVENT_JOIN_REQUEST,
|
||||
id: status.id,
|
||||
});
|
||||
|
||||
const joinEventSuccess = (status: APIEntity) => ({
|
||||
type: EVENT_JOIN_SUCCESS,
|
||||
id: status.id,
|
||||
});
|
||||
|
||||
const joinEventFail = (error: AxiosError, status: StatusEntity, previousState: string | null) => ({
|
||||
type: EVENT_JOIN_FAIL,
|
||||
error,
|
||||
id: status.id,
|
||||
previousState,
|
||||
});
|
||||
|
||||
const leaveEvent = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const status = getState().statuses.get(id);
|
||||
|
||||
if (!status || !status.event || !status.event.join_state) {
|
||||
return dispatch(noOp);
|
||||
}
|
||||
|
||||
dispatch(leaveEventRequest(status));
|
||||
|
||||
return api(getState).post(`/api/v1/pleroma/events/${id}/leave`).then(({ data }) => {
|
||||
dispatch(importFetchedStatus(data));
|
||||
dispatch(leaveEventSuccess(data));
|
||||
}).catch(function(error) {
|
||||
dispatch(leaveEventFail(error, status));
|
||||
});
|
||||
};
|
||||
|
||||
const leaveEventRequest = (status: StatusEntity) => ({
|
||||
type: EVENT_LEAVE_REQUEST,
|
||||
id: status.id,
|
||||
});
|
||||
|
||||
const leaveEventSuccess = (status: APIEntity) => ({
|
||||
type: EVENT_LEAVE_SUCCESS,
|
||||
id: status.id,
|
||||
});
|
||||
|
||||
const leaveEventFail = (error: AxiosError, status: StatusEntity) => ({
|
||||
type: EVENT_LEAVE_FAIL,
|
||||
id: status.id,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchEventParticipations = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchEventParticipationsRequest(id));
|
||||
|
||||
return api(getState).get(`/api/v1/pleroma/events/${id}/participations`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
return dispatch(fetchEventParticipationsSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchEventParticipationsFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchEventParticipationsRequest = (id: string) => ({
|
||||
type: EVENT_PARTICIPATIONS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const fetchEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
|
||||
type: EVENT_PARTICIPATIONS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchEventParticipationsFail = (id: string, error: AxiosError) => ({
|
||||
type: EVENT_PARTICIPATIONS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const expandEventParticipations = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().user_lists.event_participations.get(id)?.next || null;
|
||||
|
||||
if (url === null) {
|
||||
return dispatch(noOp);
|
||||
}
|
||||
|
||||
dispatch(expandEventParticipationsRequest(id));
|
||||
|
||||
return api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
return dispatch(expandEventParticipationsSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandEventParticipationsFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandEventParticipationsRequest = (id: string) => ({
|
||||
type: EVENT_PARTICIPATIONS_EXPAND_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const expandEventParticipationsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
|
||||
type: EVENT_PARTICIPATIONS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandEventParticipationsFail = (id: string, error: AxiosError) => ({
|
||||
type: EVENT_PARTICIPATIONS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchEventParticipationRequests = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchEventParticipationRequestsRequest(id));
|
||||
|
||||
return api(getState).get(`/api/v1/pleroma/events/${id}/participation_requests`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account)));
|
||||
return dispatch(fetchEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchEventParticipationRequestsFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchEventParticipationRequestsRequest = (id: string) => ({
|
||||
type: EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const fetchEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({
|
||||
type: EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS,
|
||||
id,
|
||||
participations,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchEventParticipationRequestsFail = (id: string, error: AxiosError) => ({
|
||||
type: EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const expandEventParticipationRequests = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().user_lists.event_participations.get(id)?.next || null;
|
||||
|
||||
if (url === null) {
|
||||
return dispatch(noOp);
|
||||
}
|
||||
|
||||
dispatch(expandEventParticipationRequestsRequest(id));
|
||||
|
||||
return api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account)));
|
||||
return dispatch(expandEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandEventParticipationRequestsFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandEventParticipationRequestsRequest = (id: string) => ({
|
||||
type: EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const expandEventParticipationRequestsSuccess = (id: string, participations: APIEntity[], next: string | null) => ({
|
||||
type: EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS,
|
||||
id,
|
||||
participations,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandEventParticipationRequestsFail = (id: string, error: AxiosError) => ({
|
||||
type: EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const authorizeEventParticipationRequest = (id: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(authorizeEventParticipationRequestRequest(id, accountId));
|
||||
|
||||
return api(getState)
|
||||
.post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/authorize`)
|
||||
.then(() => {
|
||||
dispatch(authorizeEventParticipationRequestSuccess(id, accountId));
|
||||
dispatch(snackbar.success(messages.authorized));
|
||||
})
|
||||
.catch(error => dispatch(authorizeEventParticipationRequestFail(id, accountId, error)));
|
||||
};
|
||||
|
||||
const authorizeEventParticipationRequestRequest = (id: string, accountId: string) => ({
|
||||
type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST,
|
||||
id,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const authorizeEventParticipationRequestSuccess = (id: string, accountId: string) => ({
|
||||
type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS,
|
||||
id,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const authorizeEventParticipationRequestFail = (id: string, accountId: string, error: AxiosError) => ({
|
||||
type: EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL,
|
||||
id,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const rejectEventParticipationRequest = (id: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(rejectEventParticipationRequestRequest(id, accountId));
|
||||
|
||||
return api(getState)
|
||||
.post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/reject`)
|
||||
.then(() => {
|
||||
dispatch(rejectEventParticipationRequestSuccess(id, accountId));
|
||||
dispatch(snackbar.success(messages.rejected));
|
||||
})
|
||||
.catch(error => dispatch(rejectEventParticipationRequestFail(id, accountId, error)));
|
||||
};
|
||||
|
||||
const rejectEventParticipationRequestRequest = (id: string, accountId: string) => ({
|
||||
type: EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST,
|
||||
id,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const rejectEventParticipationRequestSuccess = (id: string, accountId: string) => ({
|
||||
type: EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS,
|
||||
id,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const rejectEventParticipationRequestFail = (id: string, accountId: string, error: AxiosError) => ({
|
||||
type: EVENT_PARTICIPATION_REQUEST_REJECT_FAIL,
|
||||
id,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchEventIcs = (id: string) =>
|
||||
(dispatch: any, getState: () => RootState) =>
|
||||
api(getState).get(`/api/v1/pleroma/events/${id}/ics`);
|
||||
|
||||
const cancelEventCompose = () => ({
|
||||
type: EVENT_COMPOSE_CANCEL,
|
||||
});
|
||||
|
||||
const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const status = getState().statuses.get(id)!;
|
||||
|
||||
dispatch({ type: STATUS_FETCH_SOURCE_REQUEST });
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
|
||||
dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS });
|
||||
dispatch({
|
||||
type: EVENT_FORM_SET,
|
||||
status,
|
||||
text: response.data.text,
|
||||
location: response.data.location,
|
||||
});
|
||||
dispatch(openModal('COMPOSE_EVENT'));
|
||||
}).catch(error => {
|
||||
dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const fetchRecentEvents = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (getState().status_lists.get('recent_events')?.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: RECENT_EVENTS_FETCH_REQUEST });
|
||||
|
||||
api(getState).get('/api/v1/timelines/public?only_events=true').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch({
|
||||
type: RECENT_EVENTS_FETCH_SUCCESS,
|
||||
statuses: response.data,
|
||||
next: next ? next.uri : null,
|
||||
});
|
||||
}).catch(error => {
|
||||
dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const fetchJoinedEvents = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (getState().status_lists.get('joined_events')?.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: JOINED_EVENTS_FETCH_REQUEST });
|
||||
|
||||
api(getState).get('/api/v1/pleroma/events/joined_events').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch({
|
||||
type: JOINED_EVENTS_FETCH_SUCCESS,
|
||||
statuses: response.data,
|
||||
next: next ? next.uri : null,
|
||||
});
|
||||
}).catch(error => {
|
||||
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
LOCATION_SEARCH_REQUEST,
|
||||
LOCATION_SEARCH_SUCCESS,
|
||||
LOCATION_SEARCH_FAIL,
|
||||
EDIT_EVENT_NAME_CHANGE,
|
||||
EDIT_EVENT_DESCRIPTION_CHANGE,
|
||||
EDIT_EVENT_START_TIME_CHANGE,
|
||||
EDIT_EVENT_END_TIME_CHANGE,
|
||||
EDIT_EVENT_HAS_END_TIME_CHANGE,
|
||||
EDIT_EVENT_APPROVAL_REQUIRED_CHANGE,
|
||||
EDIT_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,
|
||||
EVENT_JOIN_REQUEST,
|
||||
EVENT_JOIN_SUCCESS,
|
||||
EVENT_JOIN_FAIL,
|
||||
EVENT_LEAVE_REQUEST,
|
||||
EVENT_LEAVE_SUCCESS,
|
||||
EVENT_LEAVE_FAIL,
|
||||
EVENT_PARTICIPATIONS_FETCH_REQUEST,
|
||||
EVENT_PARTICIPATIONS_FETCH_SUCCESS,
|
||||
EVENT_PARTICIPATIONS_FETCH_FAIL,
|
||||
EVENT_PARTICIPATIONS_EXPAND_REQUEST,
|
||||
EVENT_PARTICIPATIONS_EXPAND_SUCCESS,
|
||||
EVENT_PARTICIPATIONS_EXPAND_FAIL,
|
||||
EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST,
|
||||
EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL,
|
||||
EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST,
|
||||
EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL,
|
||||
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST,
|
||||
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL,
|
||||
EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST,
|
||||
EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUEST_REJECT_FAIL,
|
||||
EVENT_COMPOSE_CANCEL,
|
||||
EVENT_FORM_SET,
|
||||
RECENT_EVENTS_FETCH_REQUEST,
|
||||
RECENT_EVENTS_FETCH_SUCCESS,
|
||||
RECENT_EVENTS_FETCH_FAIL,
|
||||
JOINED_EVENTS_FETCH_REQUEST,
|
||||
JOINED_EVENTS_FETCH_SUCCESS,
|
||||
JOINED_EVENTS_FETCH_FAIL,
|
||||
locationSearch,
|
||||
changeEditEventName,
|
||||
changeEditEventDescription,
|
||||
changeEditEventStartTime,
|
||||
changeEditEventEndTime,
|
||||
changeEditEventHasEndTime,
|
||||
changeEditEventApprovalRequired,
|
||||
changeEditEventLocation,
|
||||
uploadEventBanner,
|
||||
uploadEventBannerRequest,
|
||||
uploadEventBannerProgress,
|
||||
uploadEventBannerSuccess,
|
||||
uploadEventBannerFail,
|
||||
undoUploadEventBanner,
|
||||
submitEvent,
|
||||
submitEventRequest,
|
||||
submitEventSuccess,
|
||||
submitEventFail,
|
||||
joinEvent,
|
||||
joinEventRequest,
|
||||
joinEventSuccess,
|
||||
joinEventFail,
|
||||
leaveEvent,
|
||||
leaveEventRequest,
|
||||
leaveEventSuccess,
|
||||
leaveEventFail,
|
||||
fetchEventParticipations,
|
||||
fetchEventParticipationsRequest,
|
||||
fetchEventParticipationsSuccess,
|
||||
fetchEventParticipationsFail,
|
||||
expandEventParticipations,
|
||||
expandEventParticipationsRequest,
|
||||
expandEventParticipationsSuccess,
|
||||
expandEventParticipationsFail,
|
||||
fetchEventParticipationRequests,
|
||||
fetchEventParticipationRequestsRequest,
|
||||
fetchEventParticipationRequestsSuccess,
|
||||
fetchEventParticipationRequestsFail,
|
||||
expandEventParticipationRequests,
|
||||
expandEventParticipationRequestsRequest,
|
||||
expandEventParticipationRequestsSuccess,
|
||||
expandEventParticipationRequestsFail,
|
||||
authorizeEventParticipationRequest,
|
||||
authorizeEventParticipationRequestRequest,
|
||||
authorizeEventParticipationRequestSuccess,
|
||||
authorizeEventParticipationRequestFail,
|
||||
rejectEventParticipationRequest,
|
||||
rejectEventParticipationRequestRequest,
|
||||
rejectEventParticipationRequestSuccess,
|
||||
rejectEventParticipationRequestFail,
|
||||
fetchEventIcs,
|
||||
cancelEventCompose,
|
||||
editEvent,
|
||||
fetchRecentEvents,
|
||||
fetchJoinedEvents,
|
||||
};
|
@ -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;
|
@ -0,0 +1,93 @@
|
||||
import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import EventActionButton from 'soapbox/features/event/components/event-action-button';
|
||||
import EventDate from 'soapbox/features/event/components/event-date';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Icon from './icon';
|
||||
import { Button, HStack, Stack, Text } from './ui';
|
||||
import VerificationBadge from './verification-badge';
|
||||
|
||||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
||||
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
||||
});
|
||||
|
||||
interface IEventPreview {
|
||||
status: StatusEntity
|
||||
className?: string
|
||||
hideAction?: boolean
|
||||
floatingAction?: boolean
|
||||
}
|
||||
|
||||
const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction, floatingAction = true }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
|
||||
const account = status.account as AccountEntity;
|
||||
const event = status.event!;
|
||||
|
||||
const banner = event.banner;
|
||||
|
||||
const action = !hideAction && (account.id === me ? (
|
||||
<Button
|
||||
size='sm'
|
||||
theme={floatingAction ? 'secondary' : 'primary'}
|
||||
to={`/@${account.acct}/events/${status.id}`}
|
||||
>
|
||||
<FormattedMessage id='event.manage' defaultMessage='Manage' />
|
||||
</Button>
|
||||
) : (
|
||||
<EventActionButton
|
||||
status={status}
|
||||
theme={floatingAction ? 'secondary' : 'primary'}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className={classNames('w-full rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden', className)}>
|
||||
<div className='absolute top-28 right-3'>
|
||||
{floatingAction && action}
|
||||
</div>
|
||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
|
||||
</div>
|
||||
<Stack className='p-2.5' space={2}>
|
||||
<HStack space={2} alignItems='center' justifyContent='between'>
|
||||
<Text weight='semibold' truncate>{event.name}</Text>
|
||||
|
||||
{!floatingAction && action}
|
||||
</HStack>
|
||||
|
||||
<div className='flex gap-y-1 gap-x-2 flex-wrap text-gray-700 dark:text-gray-600'>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Icon src={require('@tabler/icons/user.svg')} />
|
||||
<HStack space={1} alignItems='center' grow>
|
||||
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
|
||||
{account.verified && <VerificationBadge />}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<EventDate status={status} />
|
||||
|
||||
{event.location && (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Icon src={require('@tabler/icons/map-pin.svg')} />
|
||||
<span>
|
||||
{event.location.get('name')}
|
||||
</span>
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventPreview;
|
@ -0,0 +1,110 @@
|
||||
import classNames from 'clsx';
|
||||
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;
|
@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinEvent, leaveEvent } from 'soapbox/actions/events';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import type { ButtonThemes } from 'soapbox/components/ui/button/useButtonStyles';
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
||||
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
||||
});
|
||||
|
||||
interface IEventAction {
|
||||
status: StatusEntity
|
||||
theme?: ButtonThemes
|
||||
}
|
||||
|
||||
const EventActionButton: React.FC<IEventAction> = ({ status, theme = 'secondary' }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
|
||||
const event = status.event!;
|
||||
|
||||
const handleJoin: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (event.join_mode === 'free') {
|
||||
dispatch(joinEvent(status.id));
|
||||
} else {
|
||||
dispatch(openModal('JOIN_EVENT', {
|
||||
statusId: status.id,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeave: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (event.join_mode === 'restricted') {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.leaveMessage),
|
||||
confirm: intl.formatMessage(messages.leaveConfirm),
|
||||
onConfirm: () => dispatch(leaveEvent(status.id)),
|
||||
}));
|
||||
} else {
|
||||
dispatch(leaveEvent(status.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenUnauthorizedModal: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(openModal('UNAUTHORIZED', {
|
||||
action: 'JOIN',
|
||||
ap_id: status.url,
|
||||
}));
|
||||
};
|
||||
|
||||
let buttonLabel;
|
||||
let buttonIcon;
|
||||
let buttonDisabled = false;
|
||||
let buttonAction = handleLeave;
|
||||
|
||||
switch (event.join_state) {
|
||||
case 'accept':
|
||||
buttonLabel = <FormattedMessage id='event.join_state.accept' defaultMessage='Going' />;
|
||||
buttonIcon = require('@tabler/icons/check.svg');
|
||||
break;
|
||||
case 'pending':
|
||||
buttonLabel = <FormattedMessage id='event.join_state.pending' defaultMessage='Pending' />;
|
||||
break;
|
||||
case 'reject':
|
||||
buttonLabel = <FormattedMessage id='event.join_state.rejected' defaultMessage='Going' />;
|
||||
buttonIcon = require('@tabler/icons/ban.svg');
|
||||
buttonDisabled = true;
|
||||
break;
|
||||
default:
|
||||
buttonLabel = <FormattedMessage id='event.join_state.empty' defaultMessage='Participate' />;
|
||||
buttonAction = me ? handleJoin : handleOpenUnauthorizedModal;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size='sm'
|
||||
theme={theme}
|
||||
icon={buttonIcon}
|
||||
onClick={buttonAction}
|
||||
disabled={buttonDisabled}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventActionButton;
|
@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { FormattedDate } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IEventDate {
|
||||
status: StatusEntity,
|
||||
}
|
||||
|
||||
const EventDate: React.FC<IEventDate> = ({ status }) => {
|
||||
const event = status.event!;
|
||||
|
||||
if (!event.start_time) return null;
|
||||
|
||||
const startDate = new Date(event.start_time);
|
||||
|
||||
let date;
|
||||
|
||||
if (event.end_time) {
|
||||
const endDate = new Date(event.end_time);
|
||||
|
||||
const sameYear = startDate.getFullYear() === endDate.getFullYear();
|
||||
const sameDay = startDate.getDate() === endDate.getDate() && startDate.getMonth() === endDate.getMonth() && sameYear;
|
||||
|
||||
if (sameDay) {
|
||||
date = (
|
||||
<>
|
||||
<FormattedDate value={event.start_time} year={sameYear ? undefined : '2-digit'} month='short' day='2-digit' weekday='short' hour='2-digit' minute='2-digit' />
|
||||
{' - '}
|
||||
<FormattedDate value={event.end_time} hour='2-digit' minute='2-digit' />
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
date = (
|
||||
<>
|
||||
<FormattedDate value={event.start_time} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||
{' - '}
|
||||
<FormattedDate value={event.end_time} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
date = (
|
||||
<FormattedDate value={event.start_time} year='2-digit' month='short' day='2-digit' weekday='short' hour='2-digit' minute='2-digit' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Icon src={require('@tabler/icons/calendar.svg')} />
|
||||
<span>{date}</span>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDate;
|
@ -0,0 +1,452 @@
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import { blockAccount } from 'soapbox/actions/accounts';
|
||||
import { launchChat } from 'soapbox/actions/chats';
|
||||
import { directCompose, mentionCompose, quoteCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent, fetchEventIcs } from 'soapbox/actions/events';
|
||||
import { toggleBookmark, togglePin } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||
import { initReport } from 'soapbox/actions/reports';
|
||||
import { deleteStatus } from 'soapbox/actions/statuses';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Button, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList, Stack, Text } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import PlaceholderEventHeader from '../../placeholder/components/placeholder-event-header';
|
||||
import EventActionButton from '../components/event-action-button';
|
||||
import EventDate from '../components/event-date';
|
||||
|
||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
exportIcs: { id: 'event.export_ics', defaultMessage: 'Export to your calendar' },
|
||||
copy: { id: 'event.copy', defaultMessage: 'Copy link to event' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
|
||||
quotePost: { id: 'status.quote', defaultMessage: 'Quote post' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' },
|
||||
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
|
||||
adminStatus: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||
markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' },
|
||||
markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' },
|
||||
deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' },
|
||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
deleteConfirm: { id: 'confirmations.delete_event.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.delete_event.heading', defaultMessage: 'Delete event' },
|
||||
deleteMessage: { id: 'confirmations.delete_event.message', defaultMessage: 'Are you sure you want to delete this event?' },
|
||||
});
|
||||
|
||||
interface IEventHeader {
|
||||
status?: StatusEntity,
|
||||
}
|
||||
|
||||
const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const features = useFeatures();
|
||||
const ownAccount = useOwnAccount();
|
||||
const isStaff = ownAccount ? ownAccount.staff : false;
|
||||
const isAdmin = ownAccount ? ownAccount.admin : false;
|
||||
|
||||
if (!status || !status.event) {
|
||||
return (
|
||||
<>
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div className='relative h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50' />
|
||||
</div>
|
||||
|
||||
<PlaceholderEventHeader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const account = status.account as AccountEntity;
|
||||
const event = status.event;
|
||||
const banner = event.banner;
|
||||
|
||||
const username = account.username;
|
||||
|
||||
const handleHeaderClick: React.MouseEventHandler<HTMLAnchorElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList([event.banner]) }));
|
||||
};
|
||||
|
||||
const handleExportClick = () => {
|
||||
dispatch(fetchEventIcs(status.id)).then((response) => {
|
||||
download(response, 'calendar.ics');
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const { uri } = status;
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
textarea.textContent = uri;
|
||||
textarea.style.position = 'fixed';
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch {
|
||||
// Do nothing
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookmarkClick = () => {
|
||||
dispatch(toggleBookmark(status));
|
||||
};
|
||||
|
||||
const handleQuoteClick = () => {
|
||||
dispatch(quoteCompose(status));
|
||||
};
|
||||
|
||||
const handlePinClick = () => {
|
||||
dispatch(togglePin(status));
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteStatus(status.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMentionClick = () => {
|
||||
dispatch(mentionCompose(account));
|
||||
};
|
||||
|
||||
const handleChatClick = () => {
|
||||
dispatch(launchChat(account.id, history));
|
||||
};
|
||||
|
||||
const handleDirectClick = () => {
|
||||
dispatch(directCompose(account));
|
||||
};
|
||||
|
||||
const handleMuteClick = () => {
|
||||
dispatch(initMuteModal(account));
|
||||
};
|
||||
|
||||
const handleBlockClick = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.acct}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(blockAccount(account.id)),
|
||||
secondary: intl.formatMessage(messages.blockAndReport),
|
||||
onSecondary: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
dispatch(initReport(account, status));
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReport = () => {
|
||||
dispatch(initReport(account, status));
|
||||
};
|
||||
|
||||
const handleModerate = () => {
|
||||
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
|
||||
};
|
||||
|
||||
const handleModerateStatus = () => {
|
||||
window.open(`/pleroma/admin/#/statuses/${status.id}/`, '_blank');
|
||||
};
|
||||
|
||||
const handleToggleStatusSensitivity = () => {
|
||||
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
|
||||
};
|
||||
|
||||
const handleDeleteStatus = () => {
|
||||
dispatch(deleteStatusModal(intl, status.id));
|
||||
};
|
||||
|
||||
const makeMenu = (): MenuType => {
|
||||
const menu: MenuType = [
|
||||
{
|
||||
text: intl.formatMessage(messages.exportIcs),
|
||||
action: handleExportClick,
|
||||
icon: require('@tabler/icons/calendar-plus.svg'),
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: handleCopy,
|
||||
icon: require('@tabler/icons/link.svg'),
|
||||
},
|
||||
];
|
||||
|
||||
if (!ownAccount) return menu;
|
||||
|
||||
if (features.bookmarks) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark),
|
||||
action: handleBookmarkClick,
|
||||
icon: status.bookmarked ? require('@tabler/icons/bookmark-off.svg') : require('@tabler/icons/bookmark.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (features.quotePosts) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.quotePost),
|
||||
action: handleQuoteClick,
|
||||
icon: require('@tabler/icons/quote.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
|
||||
if (ownAccount.id === account.id) {
|
||||
if (['public', 'unlisted'].includes(status.visibility)) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin),
|
||||
action: handlePinClick,
|
||||
icon: status.pinned ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.delete),
|
||||
action: handleDeleteClick,
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
} else {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mention, { name: username }),
|
||||
action: handleMentionClick,
|
||||
icon: require('@tabler/icons/at.svg'),
|
||||
});
|
||||
|
||||
if (status.getIn(['account', 'pleroma', 'accepts_chat_messages']) === true) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.chat, { name: username }),
|
||||
action: handleChatClick,
|
||||
icon: require('@tabler/icons/messages.svg'),
|
||||
});
|
||||
} else if (features.privacyScopes) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.direct, { name: username }),
|
||||
action: handleDirectClick,
|
||||
icon: require('@tabler/icons/mail.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mute, { name: username }),
|
||||
action: handleMuteClick,
|
||||
icon: require('@tabler/icons/circle-x.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.block, { name: username }),
|
||||
action: handleBlockClick,
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.report, { name: username }),
|
||||
action: handleReport,
|
||||
icon: require('@tabler/icons/flag.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isStaff) {
|
||||
menu.push(null);
|
||||
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.adminAccount, { name: account.username }),
|
||||
action: handleModerate,
|
||||
icon: require('@tabler/icons/gavel.svg'),
|
||||
});
|
||||
|
||||
if (isAdmin) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.adminStatus),
|
||||
action: handleModerateStatus,
|
||||
icon: require('@tabler/icons/pencil.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive),
|
||||
action: handleToggleStatusSensitivity,
|
||||
icon: require('@tabler/icons/alert-triangle.svg'),
|
||||
});
|
||||
|
||||
if (account.id !== ownAccount?.id) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.deleteStatus),
|
||||
action: handleDeleteStatus,
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
};
|
||||
|
||||
const handleManageClick: React.MouseEventHandler = e => {
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(editEvent(status.id));
|
||||
};
|
||||
|
||||
const handleParticipantsClick: React.MouseEventHandler = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal('EVENT_PARTICIPANTS', {
|
||||
statusId: status.id,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='-mt-4 -mx-4'>
|
||||
<div className='relative h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50'>
|
||||
{banner && (
|
||||
<a href={banner.url} onClick={handleHeaderClick} target='_blank'>
|
||||
<StillImage
|
||||
src={banner.url}
|
||||
alt={intl.formatMessage(messages.bannerHeader)}
|
||||
className='absolute inset-0 object-cover md:rounded-t-xl h-full'
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Stack space={2}>
|
||||
<HStack className='w-full' alignItems='start' space={2}>
|
||||
<Text className='flex-grow' size='lg' weight='bold'>{event.name}</Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
theme='outlined'
|
||||
className='px-2 h-[30px]'
|
||||
iconClassName='w-4 h-4'
|
||||
children={null}
|
||||
/>
|
||||
|
||||
<MenuList>
|
||||
{makeMenu().map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<div className='flex items-center'>
|
||||
{menuItem.icon && (
|
||||
<SvgIcon src={menuItem.icon} className='mr-3 h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
|
||||
)}
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
{account.id === ownAccount?.id ? (
|
||||
<Button
|
||||
size='sm'
|
||||
theme='secondary'
|
||||
onClick={handleManageClick}
|
||||
>
|
||||
<FormattedMessage id='event.manage' defaultMessage='Manage' />
|
||||
</Button>
|
||||
) : <EventActionButton status={status} />}
|
||||
</HStack>
|
||||
|
||||
<Stack space={1}>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Icon src={require('@tabler/icons/flag-3.svg')} />
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='event.organized_by'
|
||||
defaultMessage='Organized by {name}'
|
||||
values={{
|
||||
name: (
|
||||
<Link className='mention inline-block' to={`/@${account.acct}`}>
|
||||
<HStack space={1} alignItems='center' grow>
|
||||
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
|
||||
{account.verified && <VerificationBadge />}
|
||||
</HStack>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</HStack>
|
||||
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Icon src={require('@tabler/icons/users.svg')} />
|
||||
<a href='#' className='hover:underline' onClick={handleParticipantsClick}>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='event.participants'
|
||||
defaultMessage='{count} {rawCount, plural, one {person} other {people}} going'
|
||||
values={{
|
||||
rawCount: event.participants_count || 0,
|
||||
count: shortNumberFormat(event.participants_count || 0),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</HStack>
|
||||
|
||||
<EventDate status={status} />
|
||||
|
||||
{event.location && (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Icon src={require('@tabler/icons/map-pin.svg')} />
|
||||
<span>
|
||||
{event.location.get('name')}
|
||||
</span>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventHeader;
|
@ -0,0 +1,198 @@
|
||||
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { eventDiscussionCompose } from 'soapbox/actions/compose';
|
||||
import { fetchStatusWithContext, fetchNext } from 'soapbox/actions/statuses';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import Tombstone from 'soapbox/components/tombstone';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import PendingStatus from 'soapbox/features/ui/components/pending-status';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import ComposeForm from '../compose/components/compose-form';
|
||||
import { getDescendantsIds } from '../status';
|
||||
import ThreadStatus from '../status/components/thread-status';
|
||||
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
|
||||
|
||||
type RouteParams = { statusId: string };
|
||||
|
||||
interface IEventDiscussion {
|
||||
params: RouteParams,
|
||||
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void,
|
||||
onOpenVideo: (video: AttachmentEntity, time: number) => void,
|
||||
}
|
||||
|
||||
const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
|
||||
const descendantsIds = useAppSelector(state => {
|
||||
let descendantsIds = ImmutableOrderedSet<string>();
|
||||
|
||||
if (status) {
|
||||
const statusId = status.id;
|
||||
descendantsIds = getDescendantsIds(state, statusId);
|
||||
descendantsIds = descendantsIds.delete(statusId);
|
||||
}
|
||||
|
||||
return descendantsIds;
|
||||
});
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
||||
const [next, setNext] = useState<string>();
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const scroller = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const fetchData = async() => {
|
||||
const { params } = props;
|
||||
const { statusId } = params;
|
||||
const { next } = await dispatch(fetchStatusWithContext(statusId));
|
||||
setNext(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData().then(() => {
|
||||
setIsLoaded(true);
|
||||
}).catch(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, [props.params.statusId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded && me) dispatch(eventDiscussionCompose(`reply:${props.params.statusId}`, status!));
|
||||
}, [isLoaded, me]);
|
||||
|
||||
const handleMoveUp = (id: string) => {
|
||||
const index = ImmutableList(descendantsIds).indexOf(id);
|
||||
_selectChild(index - 1);
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string) => {
|
||||
const index = ImmutableList(descendantsIds).indexOf(id);
|
||||
_selectChild(index + 1);
|
||||
};
|
||||
|
||||
const _selectChild = (index: number) => {
|
||||
scroller.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const element = document.querySelector<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderTombstone = (id: string) => {
|
||||
return (
|
||||
<div className='py-4 pb-8'>
|
||||
<Tombstone
|
||||
key={id}
|
||||
id={id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatus = (id: string) => {
|
||||
return (
|
||||
<ThreadStatus
|
||||
key={id}
|
||||
id={id}
|
||||
focusedStatusId={status!.id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingStatus = (id: string) => {
|
||||
const idempotencyKey = id.replace(/^末pending-/, '');
|
||||
|
||||
return (
|
||||
<PendingStatus
|
||||
key={id}
|
||||
idempotencyKey={idempotencyKey}
|
||||
thread
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderChildren = (list: ImmutableOrderedSet<string>) => {
|
||||
return list.map(id => {
|
||||
if (id.endsWith('-tombstone')) {
|
||||
return renderTombstone(id);
|
||||
} else if (id.startsWith('末pending-')) {
|
||||
return renderPendingStatus(id);
|
||||
} else {
|
||||
return renderStatus(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleLoadMore = useCallback(debounce(() => {
|
||||
if (next && status) {
|
||||
dispatch(fetchNext(status.id, next)).then(({ next }) => {
|
||||
setNext(next);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 300, { leading: true }), [next, status]);
|
||||
|
||||
const hasDescendants = descendantsIds.size > 0;
|
||||
|
||||
if (!status && isLoaded) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
} else if (!status) {
|
||||
return (
|
||||
<PlaceholderStatus />
|
||||
);
|
||||
}
|
||||
|
||||
const children: JSX.Element[] = [];
|
||||
|
||||
if (hasDescendants) {
|
||||
children.push(...renderChildren(descendantsIds).toArray());
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
{me && <div className='p-2 pt-0 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||
<ComposeForm id={`reply:${status.id}`} autoFocus={false} event={status.id} />
|
||||
</div>}
|
||||
<div ref={node} className='thread p-0 sm:p-2 shadow-none'>
|
||||
<ScrollableList
|
||||
id='thread'
|
||||
ref={scroller}
|
||||
hasMore={!!next}
|
||||
onLoadMore={handleLoadMore}
|
||||
placeholderComponent={() => <PlaceholderStatus thread />}
|
||||
initialTopMostItemIndex={0}
|
||||
emptyMessage={<FormattedMessage id='event.discussion.empty' defaultMessage='No one has commented this event yet. When someone does, they will appear here.' />}
|
||||
>
|
||||
{children}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDiscussion;
|
@ -0,0 +1,178 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchStatus } from 'soapbox/actions/statuses';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import StatusContent from 'soapbox/components/status-content';
|
||||
import StatusMedia from 'soapbox/components/status-media';
|
||||
import TranslateButton from 'soapbox/components/translate-button';
|
||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
|
||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { defaultMediaVisibility } from 'soapbox/utils/status';
|
||||
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
type RouteParams = { statusId: string };
|
||||
|
||||
interface IEventInformation {
|
||||
params: RouteParams,
|
||||
}
|
||||
|
||||
const EventInformation: React.FC<IEventInformation> = ({ params }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector(state => getStatus(state, { id: params.statusId })) as StatusEntity;
|
||||
|
||||
const settings = useSettings();
|
||||
const displayMedia = settings.get('displayMedia') as string;
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
||||
const [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchStatus(params.statusId)).then(() => {
|
||||
setIsLoaded(true);
|
||||
}).catch(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
|
||||
setShowMedia(defaultMediaVisibility(status, displayMedia));
|
||||
}, [params.statusId]);
|
||||
|
||||
const handleToggleMediaVisibility = () => {
|
||||
setShowMedia(!showMedia);
|
||||
};
|
||||
|
||||
const renderEventLocation = useCallback(() => {
|
||||
const event = status!.event!;
|
||||
|
||||
return event.location && (
|
||||
<Stack space={1}>
|
||||
<Text size='xl' weight='bold'>
|
||||
<FormattedMessage id='event.location' defaultMessage='Location' />
|
||||
</Text>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Icon src={require('@tabler/icons/map-pin.svg')} />
|
||||
<Text>
|
||||
{event.location.get('name')}
|
||||
<br />
|
||||
{!!event.location.get('street')?.trim() && (<>
|
||||
{event.location.get('street')}
|
||||
<br />
|
||||
</>)}
|
||||
{[event.location.get('postalCode'), event.location.get('locality'), event.location.get('country')].filter(text => text).join(', ')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
}, [status]);
|
||||
|
||||
const renderEventDate = useCallback(() => {
|
||||
const event = status!.event!;
|
||||
|
||||
if (!event.start_time) return null;
|
||||
|
||||
const startDate = new Date(event.start_time);
|
||||
const endDate = event.end_time && new Date(event.end_time);
|
||||
|
||||
const sameDay = endDate && startDate.getDate() === endDate.getDate() && startDate.getMonth() === endDate.getMonth() && startDate.getFullYear() === endDate.getFullYear();
|
||||
|
||||
return (
|
||||
<Stack space={1}>
|
||||
<Text size='xl' weight='bold'>
|
||||
<FormattedMessage id='event.date' defaultMessage='Date' />
|
||||
</Text>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Icon src={require('@tabler/icons/calendar.svg')} />
|
||||
<Text>
|
||||
<FormattedDate
|
||||
value={startDate}
|
||||
year='numeric'
|
||||
month='long'
|
||||
day='2-digit'
|
||||
weekday='long'
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
{endDate && (<>
|
||||
{' - '}
|
||||
<FormattedDate
|
||||
value={endDate}
|
||||
year={sameDay ? undefined : 'numeric'}
|
||||
month={sameDay ? undefined : 'long'}
|
||||
day={sameDay ? undefined : '2-digit'}
|
||||
weekday={sameDay ? undefined : 'long'}
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</>)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
}, [status]);
|
||||
|
||||
const renderLinks = useCallback(() => {
|
||||
if (!status.event?.links.size) return null;
|
||||
|
||||
return (
|
||||
<Stack space={1}>
|
||||
<Text size='xl' weight='bold'>
|
||||
<FormattedMessage id='event.website' defaultMessage='External links' />
|
||||
</Text>
|
||||
|
||||
{status.event.links.map(link => (
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Icon src={require('@tabler/icons/link.svg')} />
|
||||
<a href={link.remote_url || link.url} className='text-primary-600 dark:text-accent-blue hover:underline' target='_blank'>
|
||||
{(link.remote_url || link.url).replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</HStack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}, [status]);
|
||||
|
||||
if (!status && isLoaded) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
} else if (!status) return null;
|
||||
|
||||
return (
|
||||
<Stack className='mt-4 sm:p-2' space={2}>
|
||||
{!!status.contentHtml.trim() && (
|
||||
<Stack space={1}>
|
||||
<Text size='xl' weight='bold'>
|
||||
<FormattedMessage id='event.description' defaultMessage='Description' />
|
||||
</Text>
|
||||
|
||||
<StatusContent status={status} collapsable={false} translatable />
|
||||
|
||||
<TranslateButton status={status} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<StatusMedia
|
||||
status={status}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
/>
|
||||
|
||||
{status.quote && status.pleroma.get('quote_visible', true) && (
|
||||
<QuotedStatus statusId={status.quote as string} />
|
||||
)}
|
||||
|
||||
{renderEventLocation()}
|
||||
|
||||
{renderEventDate()}
|
||||
|
||||
{renderLinks()}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventInformation;
|
@ -0,0 +1,83 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import EventPreview from 'soapbox/components/event-preview';
|
||||
import { Card, Icon } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import PlaceholderEventPreview from '../../placeholder/components/placeholder-event-preview';
|
||||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
const Event = ({ id }: { id: string }) => {
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector(state => getStatus(state, { id }));
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className='w-full px-1'
|
||||
to={`/@${status.getIn(['account', 'acct'])}/events/${status.id}`}
|
||||
>
|
||||
<EventPreview status={status} floatingAction={false} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface IEventCarousel {
|
||||
statusIds: ImmutableOrderedSet<string>
|
||||
isLoading?: boolean | null
|
||||
emptyMessage: React.ReactNode
|
||||
}
|
||||
|
||||
const EventCarousel: React.FC<IEventCarousel> = ({ statusIds, isLoading, emptyMessage }) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const handleChangeIndex = (index: number) => {
|
||||
setIndex(index % statusIds.size);
|
||||
};
|
||||
|
||||
if (statusIds.size === 0) {
|
||||
if (isLoading) {
|
||||
return <PlaceholderEventPreview />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant='rounded' size='lg'>
|
||||
{emptyMessage}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='relative -mx-1'>
|
||||
{index !== 0 && (
|
||||
<div className='z-10 absolute left-3 top-1/2 -mt-4'>
|
||||
<button
|
||||
onClick={() => handleChangeIndex(index - 1)}
|
||||
className='bg-white/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-left.svg')} className='text-black dark:text-white h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
|
||||
{statusIds.map(statusId => <Event key={statusId} id={statusId} />)}
|
||||
</ReactSwipeableViews>
|
||||
{index !== statusIds.size - 1 && (
|
||||
<div className='z-10 absolute right-3 top-1/2 -mt-4'>
|
||||
<button
|
||||
onClick={() => handleChangeIndex(index + 1)}
|
||||
className='bg-white/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='text-black dark:text-white h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCarousel;
|
@ -0,0 +1,68 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchJoinedEvents, fetchRecentEvents } from 'soapbox/actions/events';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button, CardBody, CardHeader, CardTitle, Column, HStack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import EventCarousel from './components/event-carousel';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.events', defaultMessage: 'Events' },
|
||||
});
|
||||
|
||||
const Events = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const recentEvents = useAppSelector((state) => state.status_lists.get('recent_events')!.items);
|
||||
const recentEventsLoading = useAppSelector((state) => state.status_lists.get('recent_events')!.isLoading);
|
||||
const joinedEvents = useAppSelector((state) => state.status_lists.get('joined_events')!.items);
|
||||
const joinedEventsLoading = useAppSelector((state) => state.status_lists.get('joined_events')!.isLoading);
|
||||
|
||||
const onComposeEvent = () => {
|
||||
dispatch(openModal('COMPOSE_EVENT'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchRecentEvents());
|
||||
dispatch(fetchJoinedEvents());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<HStack className='mb-4' space={2} justifyContent='between'>
|
||||
<CardTitle title='Recent events' />
|
||||
<Button
|
||||
className='ml-auto'
|
||||
theme='primary'
|
||||
size='sm'
|
||||
onClick={onComposeEvent}
|
||||
>
|
||||
Create event
|
||||
</Button>
|
||||
</HStack>
|
||||
<CardBody className='mb-2'>
|
||||
<EventCarousel
|
||||
statusIds={recentEvents}
|
||||
isLoading={recentEventsLoading}
|
||||
emptyMessage={<FormattedMessage id='events.recent_events.empty' defaultMessage='There are no public events yet.' />}
|
||||
/>
|
||||
</CardBody>
|
||||
<CardHeader>
|
||||
<CardTitle title='Joined events' />
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<EventCarousel
|
||||
statusIds={joinedEvents}
|
||||
isLoading={joinedEventsLoading}
|
||||
emptyMessage={<FormattedMessage id='events.joined_events.empty' defaultMessage="You haven't joined any event yet." />}
|
||||
/>
|
||||
</CardBody>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderEventHeader = () => {
|
||||
const eventNameLength = randomIntFromInterval(5, 25);
|
||||
const organizerNameLength = randomIntFromInterval(5, 30);
|
||||
const dateLength = randomIntFromInterval(5, 30);
|
||||
const locationLength = randomIntFromInterval(5, 30);
|
||||
|
||||
return (
|
||||
<Stack className='animate-pulse text-primary-50 dark:text-primary-800' space={2}>
|
||||
<p className='text-lg'>{generateText(eventNameLength)}</p>
|
||||
|
||||
<Stack space={1}>
|
||||
<p>{generateText(organizerNameLength)}</p>
|
||||
<p>{generateText(dateLength)}</p>
|
||||
<p>{generateText(locationLength)}</p>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderEventHeader;
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderEventPreview = () => {
|
||||
const eventNameLength = randomIntFromInterval(5, 25);
|
||||
const nameLength = randomIntFromInterval(5, 15);
|
||||
|
||||
return (
|
||||
<div className='w-full rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden animate-pulse text-primary-50 dark:text-primary-800'>
|
||||
<div className='bg-primary-200 dark:bg-gray-600 h-40' />
|
||||
<Stack className='p-2.5' space={2}>
|
||||
<Text weight='semibold'>{generateText(eventNameLength)}</Text>
|
||||
|
||||
<div className='flex gap-y-1 gap-x-2 flex-wrap text-gray-700 dark:text-gray-600'>
|
||||
<span>{generateText(nameLength)}</span>
|
||||
<span>{generateText(nameLength)}</span>
|
||||
<span>{generateText(nameLength)}</span>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderEventPreview;
|
@ -0,0 +1,350 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import {
|
||||
changeEditEventApprovalRequired,
|
||||
changeEditEventDescription,
|
||||
changeEditEventEndTime,
|
||||
changeEditEventHasEndTime,
|
||||
changeEditEventName,
|
||||
changeEditEventStartTime,
|
||||
changeEditEventLocation,
|
||||
uploadEventBanner,
|
||||
undoUploadEventBanner,
|
||||
submitEvent,
|
||||
fetchEventParticipationRequests,
|
||||
rejectEventParticipationRequest,
|
||||
authorizeEventParticipationRequest,
|
||||
cancelEventCompose,
|
||||
} from 'soapbox/actions/events';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import { ADDRESS_ICONS } from 'soapbox/components/autosuggest-location';
|
||||
import LocationSearch from 'soapbox/components/location-search';
|
||||
import { checkEventComposeContent } from 'soapbox/components/modal-root';
|
||||
import { Button, Form, FormGroup, HStack, Icon, IconButton, Input, Modal, Spinner, Stack, Tabs, Text, Textarea } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
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: 'compose_event.fields.name_placeholder', defaultMessage: 'Name' },
|
||||
eventDescriptionPlaceholder: { id: 'compose_event.fields.description_placeholder', defaultMessage: 'Description' },
|
||||
eventStartTimePlaceholder: { id: 'compose_event.fields.start_time_placeholder', defaultMessage: 'Event begins on…' },
|
||||
eventEndTimePlaceholder: { id: 'compose_event.fields.end_time_placeholder', defaultMessage: 'Event ends on…' },
|
||||
resetLocation: { id: 'compose_event.reset_location', defaultMessage: 'Reset location' },
|
||||
edit: { id: 'compose_event.tabs.edit', defaultMessage: 'Edit details' },
|
||||
pending: { id: 'compose_event.tabs.pending', defaultMessage: 'Manage requests' },
|
||||
authorize: { id: 'compose_event.participation_requests.authorize', defaultMessage: 'Authorize' },
|
||||
reject: { id: 'compose_event.participation_requests.reject', defaultMessage: 'Reject' },
|
||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
|
||||
});
|
||||
|
||||
|
||||
interface IAccount {
|
||||
eventId: string,
|
||||
id: string,
|
||||
participationMessage: string | null,
|
||||
}
|
||||
|
||||
const Account: React.FC<IAccount> = ({ eventId, id, participationMessage }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleAuthorize = () => {
|
||||
dispatch(authorizeEventParticipationRequest(eventId, id));
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
dispatch(rejectEventParticipationRequest(eventId, id));
|
||||
};
|
||||
|
||||
return (
|
||||
<AccountContainer
|
||||
id={id}
|
||||
note={participationMessage || undefined}
|
||||
action={
|
||||
<HStack space={2}>
|
||||
<Button
|
||||
theme='secondary'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.authorize)}
|
||||
onClick={handleAuthorize}
|
||||
/>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.reject)}
|
||||
onClick={handleReject}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IComposeEventModal {
|
||||
onClose: (type?: string) => void,
|
||||
}
|
||||
|
||||
const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [tab, setTab] = useState<'edit' | 'pending'>('edit');
|
||||
|
||||
const banner = useAppSelector((state) => state.compose_event.banner);
|
||||
const isUploading = useAppSelector((state) => state.compose_event.is_uploading);
|
||||
|
||||
const name = useAppSelector((state) => state.compose_event.name);
|
||||
const description = useAppSelector((state) => state.compose_event.status);
|
||||
const startTime = useAppSelector((state) => state.compose_event.start_time);
|
||||
const endTime = useAppSelector((state) => state.compose_event.end_time);
|
||||
const approvalRequired = useAppSelector((state) => state.compose_event.approval_required);
|
||||
const location = useAppSelector((state) => state.compose_event.location);
|
||||
|
||||
const id = useAppSelector((state) => state.compose_event.id);
|
||||
|
||||
const isSubmitting = useAppSelector((state) => state.compose_event.is_submitting);
|
||||
|
||||
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
dispatch(changeEditEventName(target.value));
|
||||
};
|
||||
|
||||
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
||||
dispatch(changeEditEventDescription(target.value));
|
||||
};
|
||||
|
||||
const onChangeStartTime = (date: Date) => {
|
||||
dispatch(changeEditEventStartTime(date));
|
||||
};
|
||||
|
||||
const onChangeEndTime = (date: Date) => {
|
||||
dispatch(changeEditEventEndTime(date));
|
||||
};
|
||||
|
||||
const onChangeHasEndTime: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
dispatch(changeEditEventHasEndTime(target.checked));
|
||||
};
|
||||
|
||||
const onChangeApprovalRequired: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
dispatch(changeEditEventApprovalRequired(target.checked));
|
||||
};
|
||||
|
||||
const onChangeLocation = (value: string | null) => {
|
||||
dispatch(changeEditEventLocation(value));
|
||||
};
|
||||
|
||||
const onClickClose = () => {
|
||||
dispatch((dispatch, getState) => {
|
||||
if (checkEventComposeContent(getState().compose_event)) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: id
|
||||
? <FormattedMessage id='confirmations.cancel_event_editing.heading' defaultMessage='Cancel event editing' />
|
||||
: <FormattedMessage id='confirmations.delete_event.heading' defaultMessage='Delete event' />,
|
||||
message: id
|
||||
? <FormattedMessage id='confirmations.cancel_event_editing.message' defaultMessage='Are you sure you want to cancel editing this event? All changes will be lost.' />
|
||||
: <FormattedMessage id='confirmations.delete_event.message' defaultMessage='Are you sure you want to delete this event?' />,
|
||||
confirm: intl.formatMessage(messages.confirm),
|
||||
onConfirm: () => {
|
||||
dispatch(closeModal('COMPOSE_EVENT'));
|
||||
dispatch(cancelEventCompose());
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
onClose('COMPOSE_EVENT');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFiles = (files: FileList) => {
|
||||
dispatch(uploadEventBanner(files[0], intl));
|
||||
};
|
||||
|
||||
const handleClearBanner = () => {
|
||||
dispatch(undoUploadEventBanner());
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitEvent());
|
||||
};
|
||||
|
||||
const accounts = useAppSelector((state) => state.user_lists.event_participation_requests.get(id!)?.items);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) dispatch(fetchEventParticipationRequests(id));
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
const renderTabs = () => {
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.edit),
|
||||
action: () => setTab('edit'),
|
||||
name: 'edit',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.pending),
|
||||
action: () => setTab('pending'),
|
||||
name: 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} activeItem={tab} />;
|
||||
};
|
||||
|
||||
let body;
|
||||
if (tab === 'edit') body = (
|
||||
<Form>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='compose_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='compose_event.fields.name_label' defaultMessage='Event name' />}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.eventNamePlaceholder)}
|
||||
value={name}
|
||||
onChange={onChangeName}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='compose_event.fields.description_label' defaultMessage='Event description' />}
|
||||
hintText={<FormattedMessage id='compose_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='compose_event.fields.location_label' defaultMessage='Event location' />}
|
||||
>
|
||||
{location ? renderLocation() : (
|
||||
<LocationSearch
|
||||
onSelected={onChangeLocation}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='compose_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='compose_event.fields.has_end_time' defaultMessage='The event has end date' />
|
||||
</Text>
|
||||
</HStack>
|
||||
{endTime && (
|
||||
<FormGroup
|
||||
labelText={<FormattedMessage id='compose_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>
|
||||
)}
|
||||
{!id && (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Toggle
|
||||
icons={false}
|
||||
checked={approvalRequired}
|
||||
onChange={onChangeApprovalRequired}
|
||||
/>
|
||||
<Text tag='span' theme='muted'>
|
||||
<FormattedMessage id='compose_event.fields.approval_required' defaultMessage='I want to approve participation requests manually' />
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
else body = accounts ? (
|
||||
<Stack space={3}>
|
||||
{accounts.size > 0 ? (
|
||||
accounts.map(({ account, participation_message }) =>
|
||||
<Account key={account} eventId={id!} id={account} participationMessage={participation_message} />,
|
||||
)
|
||||
) : (
|
||||
<FormattedMessage id='empty_column.event_participant_requests' defaultMessage='There are no pending event participation requests.' />
|
||||
)}
|
||||
</Stack>
|
||||
) : <Spinner />;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={id
|
||||
? <FormattedMessage id='navigation_bar.compose_event' defaultMessage='Manage event' />
|
||||
: <FormattedMessage id='navigation_bar.create_event' defaultMessage='Create new event' />}
|
||||
confirmationAction={tab === 'edit' ? handleSubmit : undefined}
|
||||
confirmationText={id
|
||||
? <FormattedMessage id='compose_event.update' defaultMessage='Update' />
|
||||
: <FormattedMessage id='compose_event.create' defaultMessage='Create' />}
|
||||
confirmationDisabled={isSubmitting}
|
||||
onClose={onClickClose}
|
||||
>
|
||||
<Stack space={2}>
|
||||
{id && renderTabs()}
|
||||
{body}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComposeEventModal;
|
@ -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: 'compose_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;
|
@ -0,0 +1,59 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchEventParticipations } from 'soapbox/actions/events';
|
||||
import { Modal, Spinner, Stack } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
interface IEventParticipantsModal {
|
||||
onClose: (type: string) => void,
|
||||
statusId: string,
|
||||
}
|
||||
|
||||
const EventParticipantsModal: React.FC<IEventParticipantsModal> = ({ onClose, statusId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const accountIds = useAppSelector((state) => state.user_lists.event_participations.get(statusId)?.items);
|
||||
|
||||
const fetchData = () => {
|
||||
dispatch(fetchEventParticipations(statusId));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const onClickClose = () => {
|
||||
onClose('EVENT_PARTICIPANTS');
|
||||
};
|
||||
|
||||
let body;
|
||||
|
||||
if (!accountIds) {
|
||||
body = <Spinner />;
|
||||
} else {
|
||||
body = (
|
||||
<Stack space={3}>
|
||||
{accountIds.size > 0 ? (
|
||||
accountIds.map((id) =>
|
||||
<AccountContainer key={id} id={id} />,
|
||||
)
|
||||
) : (
|
||||
<FormattedMessage id='empty_column.event_participants' defaultMessage='No one joined this event yet. When someone does, they will show up here.' />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='column.event_participants' defaultMessage='Event participants' />}
|
||||
onClose={onClickClose}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventParticipantsModal;
|
@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinEvent } from 'soapbox/actions/events';
|
||||
import { closeModal } from 'soapbox/actions/modals';
|
||||
import { FormGroup, Modal, Textarea } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
hint: { id: 'join_event.hint', defaultMessage: 'You can tell the organizer why do you want to participate in this event:' },
|
||||
placeholder: { id: 'join_event.placeholder', defaultMessage: 'Message to organizer' },
|
||||
join: { id: 'join_event.join', defaultMessage: 'Request join' },
|
||||
});
|
||||
|
||||
interface IAccountNoteModal {
|
||||
statusId: string,
|
||||
}
|
||||
|
||||
const AccountNoteModal: React.FC<IAccountNoteModal> = ({ statusId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [participationMessage, setParticipationMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onClose = () => {
|
||||
dispatch(closeModal('JOIN_EVENT'));
|
||||
};
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
|
||||
setParticipationMessage(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
setIsSubmitting(true);
|
||||
dispatch(joinEvent(statusId, participationMessage)).then(() => {
|
||||
onClose();
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = e => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='join_event.title' defaultMessage='Join event' />}
|
||||
onClose={onClose}
|
||||
confirmationAction={handleSubmit}
|
||||
confirmationText={intl.formatMessage(messages.join)}
|
||||
confirmationDisabled={isSubmitting}
|
||||
>
|
||||
<FormGroup labelText={intl.formatMessage(messages.hint)}>
|
||||
<Textarea
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={participationMessage}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountNoteModal;
|
@ -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);
|
||||
}));
|
||||
};
|
@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Column, Layout, Tabs } from 'soapbox/components/ui';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import {
|
||||
EventHeader,
|
||||
CtaBanner,
|
||||
SignUpPanel,
|
||||
TrendsPanel,
|
||||
WhoToFollowPanel,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
interface IEventPage {
|
||||
params?: {
|
||||
statusId?: string,
|
||||
},
|
||||
}
|
||||
|
||||
const EventPage: React.FC<IEventPage> = ({ params, children }) => {
|
||||
const me = useAppSelector(state => state.me);
|
||||
const features = useFeatures();
|
||||
|
||||
const history = useHistory();
|
||||
const statusId = params?.statusId!;
|
||||
|
||||
const status = useAppSelector(state => getStatus(state, { id: statusId }));
|
||||
|
||||
const event = status?.event;
|
||||
|
||||
if (status && !event) {
|
||||
history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}`);
|
||||
return (
|
||||
<PlaceholderStatus />
|
||||
);
|
||||
}
|
||||
|
||||
const pathname = history.location.pathname;
|
||||
const activeItem = pathname.endsWith('/discussion') ? 'discussion' : 'info';
|
||||
|
||||
const tabs = status ? [
|
||||
{
|
||||
text: 'Information',
|
||||
to: `/@${status.getIn(['account', 'acct'])}/events/${status.id}`,
|
||||
name: 'info',
|
||||
},
|
||||
{
|
||||
text: 'Discussion',
|
||||
to: `/@${status.getIn(['account', 'acct'])}/events/${status.id}/discussion`,
|
||||
name: 'discussion',
|
||||
},
|
||||
] : [];
|
||||
|
||||
const showTabs = !['/participations', 'participation_requests'].some(path => pathname.endsWith(path));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column label={event?.name} withHeader={false}>
|
||||
<div className='space-y-4'>
|
||||
<BundleContainer fetchComponent={EventHeader}>
|
||||
{Component => <Component status={status} />}
|
||||
</BundleContainer>
|
||||
|
||||
{status && showTabs && (
|
||||
<Tabs key={`event-tabs-${status.id}`} items={tabs} activeItem={activeItem} />
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</Column>
|
||||
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={CtaBanner}>
|
||||
{Component => <Component key='cta-banner' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside>
|
||||
{!me && (
|
||||
<BundleContainer fetchComponent={SignUpPanel}>
|
||||
{Component => <Component key='sign-up-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
{features.trends && (
|
||||
<BundleContainer fetchComponent={TrendsPanel}>
|
||||
{Component => <Component limit={5} key='trends-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
{features.suggestions && (
|
||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||
{Component => <Component limit={3} key='wtf-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
</Layout.Aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventPage;
|
@ -0,0 +1,105 @@
|
||||
import { fromJS, Record as ImmutableRecord } from 'immutable';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import {
|
||||
EDIT_EVENT_APPROVAL_REQUIRED_CHANGE,
|
||||
EDIT_EVENT_DESCRIPTION_CHANGE,
|
||||
EDIT_EVENT_END_TIME_CHANGE,
|
||||
EDIT_EVENT_HAS_END_TIME_CHANGE,
|
||||
EDIT_EVENT_LOCATION_CHANGE,
|
||||
EDIT_EVENT_NAME_CHANGE,
|
||||
EDIT_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,
|
||||
EVENT_COMPOSE_CANCEL,
|
||||
EVENT_FORM_SET,
|
||||
} from 'soapbox/actions/events';
|
||||
import { normalizeAttachment, normalizeLocation } 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,
|
||||
id: null as string | null,
|
||||
});
|
||||
|
||||
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 compose_event(state = ReducerRecord(), action: AnyAction): State {
|
||||
switch (action.type) {
|
||||
case EDIT_EVENT_NAME_CHANGE:
|
||||
return state.set('name', action.value);
|
||||
case EDIT_EVENT_DESCRIPTION_CHANGE:
|
||||
return state.set('status', action.value);
|
||||
case EDIT_EVENT_START_TIME_CHANGE:
|
||||
return state.set('start_time', action.value);
|
||||
case EDIT_EVENT_END_TIME_CHANGE:
|
||||
return state.set('end_time', action.value);
|
||||
case EDIT_EVENT_HAS_END_TIME_CHANGE:
|
||||
if (action.value) return setHasEndTime(state);
|
||||
return state.set('end_time', null);
|
||||
case EDIT_EVENT_APPROVAL_REQUIRED_CHANGE:
|
||||
return state.set('approval_required', action.value);
|
||||
case EDIT_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);
|
||||
case EVENT_COMPOSE_CANCEL:
|
||||
return ReducerRecord();
|
||||
case EVENT_FORM_SET:
|
||||
return ReducerRecord({
|
||||
name: action.status.event.name,
|
||||
status: action.text,
|
||||
start_time: new Date(action.status.event.start_time),
|
||||
end_time: action.status.event.end_time ? new Date(action.status.event.end_time) : null,
|
||||
approval_required: action.status.event.join_mode !== 'free',
|
||||
banner: action.status.event.banner || null,
|
||||
location: action.location ? normalizeLocation(action.location) : null,
|
||||
progress: 0,
|
||||
is_uploading: false,
|
||||
is_submitting: false,
|
||||
id: action.status.id,
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
/** Download the file from the response instead of opening it in a tab. */
|
||||
// https://stackoverflow.com/a/53230807
|
||||
export const download = (response: AxiosResponse, filename: string) => {
|
||||
const url = URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
};
|
Loading…
Reference in new issue