kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge remote-tracking branch 'origin/develop' into entity-store
commit
92b7eb0ffe
|
@ -35,6 +35,7 @@ const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
|||
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||
const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||
const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY';
|
||||
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||
const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
|
||||
const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
|
||||
|
@ -713,6 +714,21 @@ const removeFromMentions = (composeId: string, accountId: string) =>
|
|||
});
|
||||
};
|
||||
|
||||
const eventDiscussionCompose = (composeId: string, status: Status) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const instance = state.instance;
|
||||
const { explicitAddressing } = getFeatures(instance);
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_EVENT_REPLY,
|
||||
id: composeId,
|
||||
status: status,
|
||||
account: state.accounts.get(state.me),
|
||||
explicitAddressing,
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
COMPOSE_CHANGE,
|
||||
COMPOSE_SUBMIT_REQUEST,
|
||||
|
@ -720,6 +736,7 @@ export {
|
|||
COMPOSE_SUBMIT_FAIL,
|
||||
COMPOSE_REPLY,
|
||||
COMPOSE_REPLY_CANCEL,
|
||||
COMPOSE_EVENT_REPLY,
|
||||
COMPOSE_QUOTE,
|
||||
COMPOSE_QUOTE_CANCEL,
|
||||
COMPOSE_DIRECT,
|
||||
|
@ -806,4 +823,5 @@ export {
|
|||
openComposeWithText,
|
||||
addToMentions,
|
||||
removeFromMentions,
|
||||
eventDiscussionCompose,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -69,6 +69,7 @@ interface IAccount {
|
|||
withRelationship?: boolean,
|
||||
showEdit?: boolean,
|
||||
emoji?: string,
|
||||
note?: string,
|
||||
}
|
||||
|
||||
const Account = ({
|
||||
|
@ -92,6 +93,7 @@ const Account = ({
|
|||
withRelationship = true,
|
||||
showEdit = false,
|
||||
emoji,
|
||||
note,
|
||||
}: IAccount) => {
|
||||
const overflowRef = React.useRef<HTMLDivElement>(null);
|
||||
const actionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
@ -169,7 +171,7 @@ const Account = ({
|
|||
return (
|
||||
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
|
||||
<HStack alignItems={actionAlignment} justifyContent='between'>
|
||||
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
|
||||
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
|
||||
<ProfilePopper
|
||||
condition={showProfileHoverCard}
|
||||
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||
|
@ -212,7 +214,7 @@ const Account = ({
|
|||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
||||
<Stack space={withAccountNote ? 1 : 0}>
|
||||
<Stack space={withAccountNote || note ? 1 : 0}>
|
||||
<HStack alignItems='center' space={1} style={style}>
|
||||
<Text theme='muted' size='sm' truncate>@{username}</Text>
|
||||
|
||||
|
@ -251,7 +253,14 @@ const Account = ({
|
|||
) : null}
|
||||
</HStack>
|
||||
|
||||
{withAccountNote && (
|
||||
{note ? (
|
||||
<Text
|
||||
size='sm'
|
||||
className='mr-2'
|
||||
>
|
||||
{note}
|
||||
</Text>
|
||||
) : withAccountNote && (
|
||||
<Text
|
||||
size='sm'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
|
|
|
@ -61,6 +61,7 @@ export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputEl
|
|||
maxLength?: number,
|
||||
menu?: Menu,
|
||||
resultsPosition: string,
|
||||
renderSuggestion?: React.FC<{ id: string }>,
|
||||
theme?: InputThemes,
|
||||
}
|
||||
|
||||
|
@ -203,7 +204,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
if (typeof suggestion === 'object') {
|
||||
if (this.props.renderSuggestion && typeof suggestion === 'string') {
|
||||
const RenderSuggestion = this.props.renderSuggestion;
|
||||
inner = <RenderSuggestion id={suggestion} />;
|
||||
key = suggestion;
|
||||
} else if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
} else if (suggestion[0] === '#') {
|
||||
|
|
|
@ -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;
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from 'clsx';
|
||||
import { debounce } from 'lodash';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -61,7 +61,7 @@
|
|||
|
||||
/* Emojis */
|
||||
[data-markup] img.emojione {
|
||||
@apply w-5 h-5;
|
||||
@apply w-5 h-5 m-0;
|
||||
}
|
||||
|
||||
/* Hide Markdown images (Pleroma) */
|
||||
|
|
|
@ -5,15 +5,18 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { cancelReplyCompose } from 'soapbox/actions/compose';
|
||||
import { cancelEventCompose } from 'soapbox/actions/events';
|
||||
import { openModal, closeModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch, useAppSelector, usePrevious } from 'soapbox/hooks';
|
||||
import { useAppDispatch, usePrevious } from 'soapbox/hooks';
|
||||
|
||||
import type { UnregisterCallback } from 'history';
|
||||
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
|
||||
import type { ReducerCompose } from 'soapbox/reducers/compose';
|
||||
import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event';
|
||||
|
||||
const messages = defineMessages({
|
||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
|
||||
});
|
||||
|
||||
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
|
||||
|
@ -25,6 +28,15 @@ export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>)
|
|||
].some(check => check === true);
|
||||
};
|
||||
|
||||
export const checkEventComposeContent = (compose?: ReturnType<typeof ReducerComposeEvent>) => {
|
||||
return !!compose && [
|
||||
compose.name.length > 0,
|
||||
compose.status.length > 0,
|
||||
compose.location !== null,
|
||||
compose.banner !== null,
|
||||
].some(check => check === true);
|
||||
};
|
||||
|
||||
interface IModalRoot {
|
||||
onCancel?: () => void,
|
||||
onClose: (type?: ModalType) => void,
|
||||
|
@ -46,8 +58,6 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
const prevChildren = usePrevious(children);
|
||||
const prevType = usePrevious(type);
|
||||
|
||||
const isEditing = useAppSelector(state => state.compose.get('compose-modal')?.id !== null);
|
||||
|
||||
const visible = !!children;
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
|
@ -58,13 +68,20 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
|
||||
const handleOnClose = () => {
|
||||
dispatch((_, getState) => {
|
||||
const hasComposeContent = checkComposeContent(getState().compose.get('compose-modal'));
|
||||
const compose = getState().compose.get('compose-modal');
|
||||
const hasComposeContent = checkComposeContent(compose);
|
||||
const hasEventComposeContent = checkEventComposeContent(getState().compose_event);
|
||||
|
||||
if (hasComposeContent && type === 'COMPOSE') {
|
||||
const isEditing = compose!.id !== null;
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: isEditing ? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' /> : <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
|
||||
message: isEditing ? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' /> : <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
|
||||
heading: isEditing
|
||||
? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' />
|
||||
: <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
|
||||
message: isEditing
|
||||
? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' />
|
||||
: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
|
||||
confirm: intl.formatMessage(messages.confirm),
|
||||
onConfirm: () => {
|
||||
dispatch(closeModal('COMPOSE'));
|
||||
|
@ -74,7 +91,26 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
dispatch(closeModal('CONFIRM'));
|
||||
},
|
||||
}));
|
||||
} else if (hasComposeContent && type === 'CONFIRM') {
|
||||
} else if (hasEventComposeContent && type === 'COMPOSE_EVENT') {
|
||||
const isEditing = getState().compose_event.id !== null;
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: isEditing
|
||||
? <FormattedMessage id='confirmations.cancel_event_editing.heading' defaultMessage='Cancel event editing' />
|
||||
: <FormattedMessage id='confirmations.delete_event.heading' defaultMessage='Delete event' />,
|
||||
message: isEditing
|
||||
? <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(isEditing ? messages.cancelEditing : messages.confirm),
|
||||
onConfirm: () => {
|
||||
dispatch(closeModal('COMPOSE_EVENT'));
|
||||
dispatch(cancelEventCompose());
|
||||
},
|
||||
onCancel: () => {
|
||||
dispatch(closeModal('CONFIRM'));
|
||||
},
|
||||
}));
|
||||
} else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') {
|
||||
dispatch(closeModal('CONFIRM'));
|
||||
} else {
|
||||
onClose();
|
||||
|
|
|
@ -9,6 +9,7 @@ import AccountContainer from 'soapbox/containers/account-container';
|
|||
import { useSettings } from 'soapbox/hooks';
|
||||
import { defaultMediaVisibility } from 'soapbox/utils/status';
|
||||
|
||||
import EventPreview from './event-preview';
|
||||
import OutlineBox from './outline-box';
|
||||
import StatusContent from './status-content';
|
||||
import StatusReplyMentions from './status-reply-mentions';
|
||||
|
@ -112,6 +113,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
|
|||
|
||||
<StatusReplyMentions status={status} hoverable={false} />
|
||||
|
||||
{status.event ? <EventPreview status={status} hideAction /> : (
|
||||
<Stack
|
||||
className='relative z-0'
|
||||
style={{ minHeight: status.hidden ? Math.max(minHeight, 208) + 12 : undefined }}
|
||||
|
@ -141,6 +143,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
|
|||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</OutlineBox>
|
||||
);
|
||||
|
|
|
@ -34,6 +34,7 @@ const messages = defineMessages({
|
|||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
events: { id: 'column.events', defaultMessage: 'Events' },
|
||||
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
|
||||
|
@ -208,6 +209,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{features.events && (
|
||||
<SidebarLink
|
||||
to='/events'
|
||||
icon={require('@tabler/icons/calendar-event.svg')}
|
||||
text={intl.formatMessage(messages.events)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settings.get('isDeveloper') && (
|
||||
<SidebarLink
|
||||
to='/developers'
|
||||
|
|
|
@ -13,6 +13,7 @@ const messages = defineMessages({
|
|||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
events: { id: 'column.events', defaultMessage: 'Events' },
|
||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||
});
|
||||
|
||||
|
@ -57,6 +58,14 @@ const SidebarNavigation = () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (features.events) {
|
||||
menu.push({
|
||||
to: '/events',
|
||||
text: intl.formatMessage(messages.events),
|
||||
icon: require('@tabler/icons/calendar-event.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.get('isDeveloper')) {
|
||||
menu.push({
|
||||
to: '/developers',
|
||||
|
|
|
@ -7,6 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
|||
import { showAlertForError } from 'soapbox/actions/alerts';
|
||||
import { launchChat } from 'soapbox/actions/chats';
|
||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent } from 'soapbox/actions/events';
|
||||
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
|
@ -203,7 +204,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
};
|
||||
|
||||
const handleEditClick: React.EventHandler<React.MouseEvent> = () => {
|
||||
dispatch(editStatus(status.id));
|
||||
if (status.event) dispatch(editEvent(status.id));
|
||||
else dispatch(editStatus(status.id));
|
||||
};
|
||||
|
||||
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
|
|
|
@ -43,7 +43,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
|||
const size = status.media_attachments.size;
|
||||
const firstAttachment = status.media_attachments.first();
|
||||
|
||||
let media = null;
|
||||
let media: JSX.Element | null = null;
|
||||
|
||||
const setRef = (c: HTMLDivElement): void => {
|
||||
if (c) {
|
||||
|
@ -122,7 +122,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
|||
const attachment = firstAttachment;
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer} >
|
||||
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
src={attachment.url}
|
||||
|
|
|
@ -15,6 +15,7 @@ import QuotedStatus from 'soapbox/features/status/containers/quoted-status-conta
|
|||
import { useAppDispatch, useSettings } from 'soapbox/hooks';
|
||||
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status';
|
||||
|
||||
import EventPreview from './event-preview';
|
||||
import StatusActionBar from './status-action-bar';
|
||||
import StatusContent from './status-content';
|
||||
import StatusMedia from './status-media';
|
||||
|
@ -28,7 +29,7 @@ import type {
|
|||
Status as StatusEntity,
|
||||
} from 'soapbox/types/entities';
|
||||
|
||||
// Defined in components/scrollable_list
|
||||
// Defined in components/scrollable-list
|
||||
export type ScrollPosition = { height: number, top: number };
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -383,6 +384,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
|
||||
<Stack space={4}>
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
|
@ -407,6 +409,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{(!hideActionBar && !isUnderReview) && (
|
||||
|
|
|
@ -88,9 +88,14 @@ const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
|
|||
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
|
||||
);
|
||||
|
||||
interface ICardBody {
|
||||
/** Classnames for the <div> element. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** A card's body. */
|
||||
const CardBody: React.FC = ({ children }): JSX.Element => (
|
||||
<div data-testid='card-body'>{children}</div>
|
||||
const CardBody: React.FC<ICardBody> = ({ className, children }): JSX.Element => (
|
||||
<div data-testid='card-body' className={className}>{children}</div>
|
||||
);
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardBody };
|
||||
|
|
|
@ -40,7 +40,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
|
|||
/** Right sidebar container in the UI. */
|
||||
const Aside: React.FC = ({ children }) => (
|
||||
<aside className='hidden xl:block xl:col-span-3'>
|
||||
<StickyBox offsetTop={80} className='space-y-6 pb-12' >
|
||||
<StickyBox offsetTop={80} className='space-y-6 pb-12'>
|
||||
{children}
|
||||
</StickyBox>
|
||||
</aside>
|
||||
|
|
|
@ -3,8 +3,8 @@ import React from 'react';
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Button from '../button/button';
|
||||
import HStack from '../hstack/hstack';
|
||||
import IconButton from '../icon-button/icon-button';
|
||||
import Stack from '../stack/stack';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
|
@ -115,7 +115,7 @@ const Modal: React.FC<IModal> = ({
|
|||
</div>
|
||||
|
||||
{confirmationAction && (
|
||||
<div className='mt-5 flex flex-row justify-between' data-testid='modal-actions'>
|
||||
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
|
||||
<div className='flex-grow'>
|
||||
{cancelAction && (
|
||||
<Button
|
||||
|
@ -127,7 +127,7 @@ const Modal: React.FC<IModal> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Stack space={2}>
|
||||
<HStack space={2}>
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
|
@ -146,8 +146,8 @@ const Modal: React.FC<IModal> = ({
|
|||
>
|
||||
{confirmationText}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -10,6 +10,7 @@ const spaces = {
|
|||
3: 'space-y-3',
|
||||
4: 'space-y-4',
|
||||
5: 'space-y-5',
|
||||
6: 'space-y-6',
|
||||
10: 'space-y-10',
|
||||
};
|
||||
|
||||
|
@ -23,6 +24,7 @@ const alignItemsOptions = {
|
|||
bottom: 'items-end',
|
||||
center: 'items-center',
|
||||
start: 'items-start',
|
||||
end: 'items-end',
|
||||
};
|
||||
|
||||
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly' | 'onKeyDown' | 'onPaste'> {
|
||||
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
|
||||
/** Put the cursor into the input on mount. */
|
||||
autoFocus?: boolean,
|
||||
/** The initial text in the input. */
|
||||
|
|
|
@ -6,25 +6,12 @@ import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/
|
|||
import { Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
import { parseVersion } from 'soapbox/utils/features';
|
||||
import { isNumber } from 'soapbox/utils/numbers';
|
||||
|
||||
import RegistrationModePicker from '../components/registration-mode-picker';
|
||||
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
/** Download the file from the response instead of opening it in a tab. */
|
||||
// https://stackoverflow.com/a/53230807
|
||||
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();
|
||||
};
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const instance = useInstance();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, MessageDescriptor, useIntl } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { length } from 'stringz';
|
||||
|
||||
|
@ -47,7 +47,8 @@ const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u20
|
|||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
||||
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' },
|
||||
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
|
||||
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
|
||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||
|
@ -61,9 +62,10 @@ interface IComposeForm<ID extends string> {
|
|||
shouldCondense?: boolean,
|
||||
autoFocus?: boolean,
|
||||
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
||||
event?: string,
|
||||
}
|
||||
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef }: IComposeForm<ID>) => {
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event }: IComposeForm<ID>) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
@ -240,6 +242,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
||||
|
||||
let publishText: string | JSX.Element = '';
|
||||
let textareaPlaceholder: MessageDescriptor;
|
||||
|
||||
if (isEditing) {
|
||||
publishText = intl.formatMessage(messages.saveChanges);
|
||||
|
@ -265,9 +268,17 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
publishText = intl.formatMessage(messages.schedule);
|
||||
}
|
||||
|
||||
if (event) {
|
||||
textareaPlaceholder = messages.eventPlaceholder;
|
||||
} else if (hasPoll) {
|
||||
textareaPlaceholder = messages.pollPlaceholder;
|
||||
} else {
|
||||
textareaPlaceholder = messages.placeholder;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||
{scheduledStatusCount > 0 && (
|
||||
{scheduledStatusCount > 0 && !event && (
|
||||
<Warning
|
||||
message={(
|
||||
<FormattedMessage
|
||||
|
@ -288,13 +299,13 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
<WarningContainer composeId={id} />
|
||||
|
||||
{!shouldCondense && <ReplyIndicatorContainer composeId={id} />}
|
||||
{!shouldCondense && !event && <ReplyIndicatorContainer composeId={id} />}
|
||||
|
||||
{!shouldCondense && <ReplyMentions composeId={id} />}
|
||||
{!shouldCondense && !event && <ReplyMentions composeId={id} />}
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
||||
placeholder={intl.formatMessage(hasPoll ? messages.pollPlaceholder : messages.placeholder)}
|
||||
placeholder={intl.formatMessage(textareaPlaceholder)}
|
||||
disabled={disabled}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
|
|
|
@ -11,7 +11,7 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
|||
import { DatePicker } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
const isCurrentOrFutureDate = (date: Date) => {
|
||||
export const isCurrentOrFutureDate = (date: Date) => {
|
||||
return date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0);
|
||||
};
|
||||
|
||||
|
|
|
@ -149,7 +149,7 @@ const Upload: React.FC<IUpload> = ({ composeId, id }) => {
|
|||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div
|
||||
className={classNames('compose-form__upload-thumbnail', `${mediaType}`)}
|
||||
className={classNames('compose-form__upload-thumbnail', mediaType)}
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
|
||||
|
|
|
@ -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;
|
|
@ -51,6 +51,9 @@ const icons: Record<NotificationType, string> = {
|
|||
'pleroma:emoji_reaction': require('@tabler/icons/mood-happy.svg'),
|
||||
user_approved: require('@tabler/icons/user-plus.svg'),
|
||||
update: require('@tabler/icons/pencil.svg'),
|
||||
'pleroma:event_reminder': require('@tabler/icons/calendar-time.svg'),
|
||||
'pleroma:participation_request': require('@tabler/icons/calendar-event.svg'),
|
||||
'pleroma:participation_accepted': require('@tabler/icons/calendar-event.svg'),
|
||||
};
|
||||
|
||||
const nameMessage = defineMessage({
|
||||
|
@ -107,6 +110,18 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
|
|||
id: 'notification.update',
|
||||
defaultMessage: '{name} edited a post you interacted with',
|
||||
},
|
||||
'pleroma:event_reminder': {
|
||||
id: 'notification.pleroma:event_reminder',
|
||||
defaultMessage: 'An event you are participating in starts soon',
|
||||
},
|
||||
'pleroma:participation_request': {
|
||||
id: 'notification.pleroma:participation_request',
|
||||
defaultMessage: '{name} wants to join your event',
|
||||
},
|
||||
'pleroma:participation_accepted': {
|
||||
id: 'notification.pleroma:participation_accepted',
|
||||
defaultMessage: 'You were accepted to join the event',
|
||||
},
|
||||
});
|
||||
|
||||
const buildMessage = (
|
||||
|
@ -297,6 +312,9 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
case 'poll':
|
||||
case 'update':
|
||||
case 'pleroma:emoji_reaction':
|
||||
case 'pleroma:event_reminder':
|
||||
case 'pleroma:participation_accepted':
|
||||
case 'pleroma:participation_request':
|
||||
return status && typeof status === 'object' ? (
|
||||
<Status
|
||||
status={status}
|
||||
|
|
|
@ -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;
|
|
@ -35,7 +35,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
|
|||
|
||||
if (video) {
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} >
|
||||
<Bundle fetchComponent={Video}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
preview={video.preview_url}
|
||||
|
@ -58,7 +58,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
|
|||
|
||||
if (audio) {
|
||||
media = (
|
||||
<Bundle fetchComponent={Audio} >
|
||||
<Bundle fetchComponent={Audio}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
src={audio.url}
|
||||
|
@ -73,7 +73,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
|
|||
}
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} >
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: any) => <Component media={status.media_attachments} sensitive={status.sensitive} height={110} onOpenMedia={noop} />}
|
||||
</Bundle>
|
||||
);
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import classNames from 'clsx';
|
||||
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { debounce } from 'lodash';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
|
@ -82,7 +82,7 @@ const getAncestorsIds = createSelector([
|
|||
return ancestorsIds;
|
||||
});
|
||||
|
||||
const getDescendantsIds = createSelector([
|
||||
export const getDescendantsIds = createSelector([
|
||||
(_: RootState, statusId: string) => statusId,
|
||||
(state: RootState) => state.contexts.replies,
|
||||
], (statusId, contextReplies) => {
|
||||
|
@ -425,6 +425,12 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
const hasAncestors = ancestorsIds.size > 0;
|
||||
const hasDescendants = descendantsIds.size > 0;
|
||||
|
||||
if (status?.event) {
|
||||
return (
|
||||
<Redirect to={`/@${status.getIn(['account', 'acct'])}/events/${status.id}`} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!status && isLoaded) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import { Modal } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
|
||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this modal.' },
|
||||
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
|
||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
@ -22,23 +22,13 @@ const BundleModalError: React.FC<IBundleModalError> = ({ onRetry, onClose }) =>
|
|||
};
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal error-modal'>
|
||||
<div className='error-modal__body'>
|
||||
<IconButton title={intl.formatMessage(messages.retry)} icon='refresh' onClick={handleRetry} size={64} />
|
||||
{intl.formatMessage(messages.error)}
|
||||
</div>
|
||||
|
||||
<div className='error-modal__footer'>
|
||||
<div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='error-modal__nav'
|
||||
>
|
||||
{intl.formatMessage(messages.close)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title={intl.formatMessage(messages.error)}
|
||||
confirmationAction={onClose}
|
||||
confirmationText={intl.formatMessage(messages.close)}
|
||||
secondaryAction={handleRetry}
|
||||
secondaryText={intl.formatMessage(messages.retry)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -43,6 +43,9 @@ const LinkFooter: React.FC = (): JSX.Element => {
|
|||
{features.profileDirectory && (
|
||||
<FooterLink to='/directory'><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></FooterLink>
|
||||
)}
|
||||
{features.events && (
|
||||
<FooterLink to='/events'><FormattedMessage id='navigation_bar.events' defaultMessage='Events' /></FooterLink>
|
||||
)}
|
||||
<FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
|
||||
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
|
||||
{features.filters && (
|
||||
|
|
|
@ -30,7 +30,10 @@ import {
|
|||
CompareHistoryModal,
|
||||
VerifySmsModal,
|
||||
FamiliarFollowersModal,
|
||||
ComposeEventModal,
|
||||
JoinEventModal,
|
||||
AccountModerationModal,
|
||||
EventParticipantsModal,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
import BundleContainer from '../containers/bundle-container';
|
||||
|
@ -68,7 +71,10 @@ const MODAL_COMPONENTS = {
|
|||
'COMPARE_HISTORY': CompareHistoryModal,
|
||||
'VERIFY_SMS': VerifySmsModal,
|
||||
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
|
||||
'COMPOSE_EVENT': ComposeEventModal,
|
||||
'JOIN_EVENT': JoinEventModal,
|
||||
'ACCOUNT_MODERATION': AccountModerationModal,
|
||||
'EVENT_PARTICIPANTS': EventParticipantsModal,
|
||||
};
|
||||
|
||||
export type ModalType = keyof typeof MODAL_COMPONENTS | null;
|
||||
|
|
|
@ -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;
|
|
@ -20,8 +20,6 @@ const messages = defineMessages({
|
|||
blankslate: { id: 'report.reason.blankslate', defaultMessage: 'You have removed all statuses from being selected.' },
|
||||
done: { id: 'report.done', defaultMessage: 'Done' },
|
||||
next: { id: 'report.next', defaultMessage: 'Next' },
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
|
||||
submit: { id: 'report.submit', defaultMessage: 'Submit' },
|
||||
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
||||
previous: { id: 'report.previous', defaultMessage: 'Previous' },
|
||||
|
|
|
@ -15,7 +15,7 @@ const messages = defineMessages({
|
|||
|
||||
interface IUnauthorizedModal {
|
||||
/** Unauthorized action type. */
|
||||
action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE',
|
||||
action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE' | 'JOIN',
|
||||
/** Close event handler. */
|
||||
onClose: (modalType: string) => void,
|
||||
/** ActivityPub ID of the account OR status being acted upon. */
|
||||
|
@ -89,6 +89,9 @@ const UnauthorizedModal: React.FC<IUnauthorizedModal> = ({ action, onClose, acco
|
|||
} else if (action === 'POLL_VOTE') {
|
||||
header = <FormattedMessage id='remote_interaction.poll_vote_title' defaultMessage='Vote in a poll remotely' />;
|
||||
button = <FormattedMessage id='remote_interaction.poll_vote' defaultMessage='Proceed to vote' />;
|
||||
} else if (action === 'JOIN') {
|
||||
header = <FormattedMessage id='remote_interaction.event_join_title' defaultMessage='Join an event remotely' />;
|
||||
button = <FormattedMessage id='remote_interaction.event_join' defaultMessage='Proceed to join' />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { cancelReplyCompose } from 'soapbox/actions/compose';
|
||||
import { cancelEventCompose } from 'soapbox/actions/events';
|
||||
import { closeModal } from 'soapbox/actions/modals';
|
||||
import { cancelReport } from 'soapbox/actions/reports';
|
||||
|
||||
|
@ -26,6 +27,9 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
|
|||
case 'COMPOSE':
|
||||
dispatch(cancelReplyCompose());
|
||||
break;
|
||||
case 'COMPOSE_EVENT':
|
||||
dispatch(cancelEventCompose());
|
||||
break;
|
||||
case 'REPORT':
|
||||
dispatch(cancelReport());
|
||||
break;
|
||||
|
|
|
@ -12,6 +12,7 @@ import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
|||
import { fetchChats } from 'soapbox/actions/chats';
|
||||
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
|
||||
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
||||
import { uploadEventBanner } from 'soapbox/actions/events';
|
||||
import { fetchFilters } from 'soapbox/actions/filters';
|
||||
import { fetchMarker } from 'soapbox/actions/markers';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
|
@ -28,6 +29,7 @@ import { Layout } from 'soapbox/components/ui';
|
|||
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import AdminPage from 'soapbox/pages/admin-page';
|
||||
import DefaultPage from 'soapbox/pages/default-page';
|
||||
import EventPage from 'soapbox/pages/event-page';
|
||||
import HomePage from 'soapbox/pages/home-page';
|
||||
import ProfilePage from 'soapbox/pages/profile-page';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||
|
@ -111,6 +113,9 @@ import {
|
|||
AuthTokenList,
|
||||
Quotes,
|
||||
ServiceWorkerInfo,
|
||||
EventInformation,
|
||||
EventDiscussion,
|
||||
Events,
|
||||
} from './util/async-components';
|
||||
import { WrappedRoute } from './util/react-router-helpers';
|
||||
|
||||
|
@ -248,6 +253,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
|||
<WrappedRoute path='/search' page={DefaultPage} component={Search} content={children} />
|
||||
{features.suggestions && <WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
|
||||
{features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />}
|
||||
{features.events && <WrappedRoute path='/events' page={DefaultPage} component={Events} content={children} />}
|
||||
|
||||
{features.chats && <WrappedRoute path='/chats' exact page={DefaultPage} component={ChatIndex} content={children} />}
|
||||
{features.chats && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />}
|
||||
|
@ -267,6 +273,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
|||
<WrappedRoute path='/@:username/pins' component={PinnedStatuses} page={ProfilePage} content={children} />
|
||||
<WrappedRoute path='/@:username/posts/:statusId' publicRoute exact page={StatusPage} component={Status} content={children} />
|
||||
<WrappedRoute path='/@:username/posts/:statusId/quotes' publicRoute page={StatusPage} component={Quotes} content={children} />
|
||||
<WrappedRoute path='/@:username/events/:statusId' publicRoute exact page={EventPage} component={EventInformation} content={children} />
|
||||
<WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />
|
||||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||
|
||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||
|
@ -379,7 +387,9 @@ const UI: React.FC = ({ children }) => {
|
|||
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
|
||||
const modals = getState().modals;
|
||||
const isModalOpen = modals.last()?.modalType === 'COMPOSE';
|
||||
dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl));
|
||||
const isEventsModalOpen = modals.last()?.modalType === 'COMPOSE_EVENT';
|
||||
if (isEventsModalOpen) dispatch(uploadEventBanner(e.dataTransfer.files[0], intl));
|
||||
else dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -509,3 +509,31 @@ export function AnnouncementsPanel() {
|
|||
export function Quotes() {
|
||||
return import(/*webpackChunkName: "features/quotes" */'../../quotes');
|
||||
}
|
||||
|
||||
export function ComposeEventModal() {
|
||||
return import(/* webpackChunkName: "features/compose_event_modal" */'../components/modals/compose-event-modal/compose-event-modal');
|
||||
}
|
||||
|
||||
export function JoinEventModal() {
|
||||
return import(/* webpackChunkName: "features/join_event_modal" */'../components/modals/join-event-modal');
|
||||
}
|
||||
|
||||
export function EventHeader() {
|
||||
return import(/* webpackChunkName: "features/event" */'../../event/components/event-header');
|
||||
}
|
||||
|
||||
export function EventInformation() {
|
||||
return import(/* webpackChunkName: "features/event" */'../../event/event-information');
|
||||
}
|
||||
|
||||
export function EventDiscussion() {
|
||||
return import(/* webpackChunkName: "features/event" */'../../event/event-discussion');
|
||||
}
|
||||
|
||||
export function EventParticipantsModal() {
|
||||
return import(/* webpackChunkName: "modals/event-participants-modal" */'../components/modals/event-participants-modal');
|
||||
}
|
||||
|
||||
export function Events() {
|
||||
return import(/* webpackChunkName: "features/events" */'../../events');
|
||||
}
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Опитай отново",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Klask endro",
|
||||
"bundle_column_error.title": "Fazi rouedad",
|
||||
"bundle_modal_error.close": "Serriñ",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Klask endro",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -183,7 +183,7 @@
|
|||
"bundle_column_error.retry": "Try again",
|
||||
"bundle_column_error.title": "Network error",
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this page.",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this modal.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"card.back.label": "Back",
|
||||
"chat_box.actions.send": "Send",
|
||||
|
|
|
@ -12,6 +12,7 @@ export { FilterRecord, normalizeFilter } from './filter';
|
|||
export { HistoryRecord, normalizeHistory } from './history';
|
||||
export { InstanceRecord, normalizeInstance } from './instance';
|
||||
export { ListRecord, normalizeList } from './list';
|
||||
export { LocationRecord, normalizeLocation } from './location';
|
||||
export { MentionRecord, normalizeMention } from './mention';
|
||||
export { NotificationRecord, normalizeNotification } from './notification';
|
||||
export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
|
||||
|
|
|
@ -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);
|
||||
}));
|
||||
};
|
|
@ -31,6 +31,21 @@ const MAX_DEPTH = 1;
|
|||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
export type EventJoinMode = 'free' | 'restricted' | 'invite';
|
||||
export type EventJoinState = 'pending' | 'reject' | 'accept';
|
||||
|
||||
export const EventRecord = ImmutableRecord({
|
||||
name: '',
|
||||
start_time: null as string | null,
|
||||
end_time: null as string | null,
|
||||
join_mode: null as EventJoinMode | null,
|
||||
participants_count: 0,
|
||||
location: null as ImmutableMap<string, any> | null,
|
||||
join_state: null as EventJoinState | null,
|
||||
banner: null as Attachment | null,
|
||||
links: ImmutableList<Attachment>(),
|
||||
});
|
||||
|
||||
// https://docs.joinmastodon.org/entities/status/
|
||||
export const StatusRecord = ImmutableRecord({
|
||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||
|
@ -66,6 +81,7 @@ export const StatusRecord = ImmutableRecord({
|
|||
uri: '',
|
||||
url: '',
|
||||
visibility: 'public' as StatusVisibility,
|
||||
event: null as ReturnType<typeof EventRecord> | null,
|
||||
|
||||
// Internal fields
|
||||
contentHtml: '',
|
||||
|
@ -209,6 +225,33 @@ const addInternalFields = (status: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
// Normalize event
|
||||
const normalizeEvent = (status: ImmutableMap<string, any>) => {
|
||||
if (status.getIn(['pleroma', 'event'])) {
|
||||
const firstAttachment = status.get('media_attachments').first();
|
||||
let banner = null;
|
||||
let mediaAttachments = status.get('media_attachments');
|
||||
|
||||
if (firstAttachment && firstAttachment.description === 'Banner' && firstAttachment.type === 'image') {
|
||||
banner = normalizeAttachment(firstAttachment);
|
||||
mediaAttachments = mediaAttachments.shift();
|
||||
}
|
||||
|
||||
const links = mediaAttachments.filter((attachment: Attachment) => attachment.pleroma.get('mime_type') === 'text/html');
|
||||
mediaAttachments = mediaAttachments.filter((attachment: Attachment) => attachment.pleroma.get('mime_type') !== 'text/html');
|
||||
|
||||
const event = EventRecord(
|
||||
(status.getIn(['pleroma', 'event']) as ImmutableMap<string, any>)
|
||||
.set('banner', banner)
|
||||
.set('links', links),
|
||||
);
|
||||
|
||||
status
|
||||
.set('event', event)
|
||||
.set('media_attachments', mediaAttachments);
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeStatus = (status: Record<string, any>, depth = 0) => {
|
||||
return StatusRecord(
|
||||
ImmutableMap(fromJS(status)).withMutations(status => {
|
||||
|
@ -225,6 +268,7 @@ export const normalizeStatus = (status: Record<string, any>, depth = 0) => {
|
|||
fixSensitivity(status);
|
||||
normalizeReblogQuote(status, depth);
|
||||
addInternalFields(status);
|
||||
normalizeEvent(status);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -91,6 +91,7 @@ describe('compose reducer', () => {
|
|||
it('uses \'public\' scope as default', () => {
|
||||
const action = {
|
||||
type: actions.COMPOSE_REPLY,
|
||||
id: 'compose-modal',
|
||||
status: ImmutableRecord({})(),
|
||||
account: ImmutableRecord({})(),
|
||||
};
|
||||
|
@ -101,6 +102,7 @@ describe('compose reducer', () => {
|
|||
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
||||
const action = {
|
||||
type: actions.COMPOSE_REPLY,
|
||||
id: 'compose-modal',
|
||||
status: ImmutableRecord({ visibility: 'direct' })(),
|
||||
account: ImmutableRecord({})(),
|
||||
};
|
||||
|
@ -111,6 +113,7 @@ describe('compose reducer', () => {
|
|||
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
||||
const action = {
|
||||
type: actions.COMPOSE_REPLY,
|
||||
id: 'compose-modal',
|
||||
status: ImmutableRecord({ visibility: 'private' })(),
|
||||
account: ImmutableRecord({})(),
|
||||
};
|
||||
|
@ -121,6 +124,7 @@ describe('compose reducer', () => {
|
|||
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
||||
const action = {
|
||||
type: actions.COMPOSE_REPLY,
|
||||
id: 'compose-modal',
|
||||
status: ImmutableRecord({ visibility: 'unlisted' })(),
|
||||
account: ImmutableRecord({})(),
|
||||
};
|
||||
|
@ -131,6 +135,7 @@ describe('compose reducer', () => {
|
|||
const state = initialState.set('default', ReducerCompose({ privacy: 'private' }));
|
||||
const action = {
|
||||
type: actions.COMPOSE_REPLY,
|
||||
id: 'compose-modal',
|
||||
status: ImmutableRecord({ visibility: 'public' })(),
|
||||
account: ImmutableRecord({})(),
|
||||
};
|
||||
|
@ -141,6 +146,7 @@ describe('compose reducer', () => {
|
|||
const state = initialState.set('default', ReducerCompose({ privacy: 'unlisted' }));
|
||||
const action = {
|
||||
type: actions.COMPOSE_REPLY,
|
||||
id: 'compose-modal',
|
||||
status: ImmutableRecord({ visibility: 'public' })(),
|
||||
account: ImmutableRecord({})(),
|
||||
};
|
||||
|
|
|
@ -27,6 +27,18 @@ describe('status_lists reducer', () => {
|
|||
isLoading: null,
|
||||
items: [],
|
||||
},
|
||||
joined_events: {
|
||||
next: null,
|
||||
loaded: false,
|
||||
isLoading: null,
|
||||
items: [],
|
||||
},
|
||||
recent_events: {
|
||||
next: null,
|
||||
loaded: false,
|
||||
isLoading: null,
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -48,6 +48,7 @@ import {
|
|||
COMPOSE_ADD_TO_MENTIONS,
|
||||
COMPOSE_REMOVE_FROM_MENTIONS,
|
||||
COMPOSE_SET_STATUS,
|
||||
COMPOSE_EVENT_REPLY,
|
||||
} from '../actions/compose';
|
||||
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me';
|
||||
import { SETTING_CHANGE, FE_NAME } from '../actions/settings';
|
||||
|
@ -305,7 +306,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
case COMPOSE_COMPOSING_CHANGE:
|
||||
return updateCompose(state, action.id, compose => compose.set('is_composing', action.value));
|
||||
case COMPOSE_REPLY:
|
||||
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
|
||||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||
const defaultCompose = state.get('default')!;
|
||||
|
||||
map.set('in_reply_to', action.status.get('id'));
|
||||
|
@ -317,6 +318,12 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
map.set('idempotencyKey', uuid());
|
||||
map.set('content_type', defaultCompose.content_type);
|
||||
}));
|
||||
case COMPOSE_EVENT_REPLY:
|
||||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||
map.set('in_reply_to', action.status.get('id'));
|
||||
map.set('to', statusToMentionsArray(action.status, action.account));
|
||||
map.set('idempotencyKey', uuid());
|
||||
}));
|
||||
case COMPOSE_QUOTE:
|
||||
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
|
||||
const author = action.status.getIn(['account', 'acct']);
|
||||
|
@ -341,7 +348,10 @@ export default function compose(state = initialState, action: AnyAction) {
|
|||
case COMPOSE_QUOTE_CANCEL:
|
||||
case COMPOSE_RESET:
|
||||
case COMPOSE_SUBMIT_SUCCESS:
|
||||
return updateCompose(state, action.id, () => state.get('default')!.set('idempotencyKey', uuid()));
|
||||
return updateCompose(state, action.id, () => state.get('default')!.withMutations(map => {
|
||||
map.set('idempotencyKey', uuid());
|
||||
map.set('in_reply_to', action.id.startsWith('reply:') ? action.id.slice(6) : null);
|
||||
}));
|
||||
case COMPOSE_SUBMIT_FAIL:
|
||||
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
|
||||
case COMPOSE_UPLOAD_CHANGE_FAIL:
|
||||
|
|
|
@ -20,6 +20,7 @@ import chat_message_lists from './chat-message-lists';
|
|||
import chat_messages from './chat-messages';
|
||||
import chats from './chats';
|
||||
import compose from './compose';
|
||||
import compose_event from './compose-event';
|
||||
import contexts from './contexts';
|
||||
import conversations from './conversations';
|
||||
import custom_emojis from './custom-emojis';
|
||||
|
@ -31,6 +32,7 @@ import instance from './instance';
|
|||
import listAdder from './list-adder';
|
||||
import listEditor from './list-editor';
|
||||
import lists from './lists';
|
||||
import locations from './locations';
|
||||
import me from './me';
|
||||
import meta from './meta';
|
||||
import modals from './modals';
|
||||
|
@ -87,6 +89,7 @@ const reducers = {
|
|||
lists,
|
||||
listEditor,
|
||||
listAdder,
|
||||
locations,
|
||||
filters,
|
||||
conversations,
|
||||
suggestions,
|
||||
|
@ -117,6 +120,7 @@ const reducers = {
|
|||
rules,
|
||||
history,
|
||||
announcements,
|
||||
compose_event,
|
||||
entities,
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -21,6 +21,14 @@ import {
|
|||
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
||||
BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||
} from '../actions/bookmarks';
|
||||
import {
|
||||
RECENT_EVENTS_FETCH_REQUEST,
|
||||
RECENT_EVENTS_FETCH_SUCCESS,
|
||||
RECENT_EVENTS_FETCH_FAIL,
|
||||
JOINED_EVENTS_FETCH_REQUEST,
|
||||
JOINED_EVENTS_FETCH_SUCCESS,
|
||||
JOINED_EVENTS_FETCH_FAIL,
|
||||
} from '../actions/events';
|
||||
import {
|
||||
FAVOURITED_STATUSES_FETCH_REQUEST,
|
||||
FAVOURITED_STATUSES_FETCH_SUCCESS,
|
||||
|
@ -77,6 +85,8 @@ const initialState: State = ImmutableMap({
|
|||
bookmarks: StatusListRecord(),
|
||||
pins: StatusListRecord(),
|
||||
scheduled_statuses: StatusListRecord(),
|
||||
recent_events: StatusListRecord(),
|
||||
joined_events: StatusListRecord(),
|
||||
});
|
||||
|
||||
const getStatusId = (status: string | StatusEntity) => typeof status === 'string' ? status : status.id;
|
||||
|
@ -187,6 +197,18 @@ export default function statusLists(state = initialState, action: AnyAction) {
|
|||
return normalizeList(state, `quotes:${action.statusId}`, action.statuses, action.next);
|
||||
case STATUS_QUOTES_EXPAND_SUCCESS:
|
||||
return appendToList(state, `quotes:${action.statusId}`, action.statuses, action.next);
|
||||
case RECENT_EVENTS_FETCH_REQUEST:
|
||||
return setLoading(state, 'recent_events', true);
|
||||
case RECENT_EVENTS_FETCH_FAIL:
|
||||
return setLoading(state, 'recent_events', false);
|
||||
case RECENT_EVENTS_FETCH_SUCCESS:
|
||||
return normalizeList(state, 'recent_events', action.statuses, action.next);
|
||||
case JOINED_EVENTS_FETCH_REQUEST:
|
||||
return setLoading(state, 'joined_events', true);
|
||||
case JOINED_EVENTS_FETCH_FAIL:
|
||||
return setLoading(state, 'joined_events', false);
|
||||
case JOINED_EVENTS_FETCH_SUCCESS:
|
||||
return normalizeList(state, 'joined_events', action.statuses, action.next);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,12 @@ import {
|
|||
EMOJI_REACT_REQUEST,
|
||||
UNEMOJI_REACT_REQUEST,
|
||||
} from '../actions/emoji-reacts';
|
||||
import {
|
||||
EVENT_JOIN_REQUEST,
|
||||
EVENT_JOIN_FAIL,
|
||||
EVENT_LEAVE_REQUEST,
|
||||
EVENT_LEAVE_FAIL,
|
||||
} from '../actions/events';
|
||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||
import {
|
||||
REBLOG_REQUEST,
|
||||
|
@ -281,6 +287,13 @@ export default function statuses(state = initialState, action: AnyAction): State
|
|||
return deleteTranslation(state, action.id);
|
||||
case TIMELINE_DELETE:
|
||||
return deleteStatus(state, action.id, action.references);
|
||||
case EVENT_JOIN_REQUEST:
|
||||
return state.setIn([action.id, 'event', 'join_state'], 'pending');
|
||||
case EVENT_JOIN_FAIL:
|
||||
case EVENT_LEAVE_REQUEST:
|
||||
return state.setIn([action.id, 'event', 'join_state'], null);
|
||||
case EVENT_LEAVE_FAIL:
|
||||
return state.setIn([action.id, 'event', 'join_state'], action.previousState);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -4,9 +4,9 @@ import {
|
|||
TRENDING_STATUSES_FETCH_REQUEST,
|
||||
TRENDING_STATUSES_FETCH_SUCCESS,
|
||||
} from 'soapbox/actions/trending-statuses';
|
||||
import { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
items: ImmutableOrderedSet<string>(),
|
||||
|
|
|
@ -16,11 +16,11 @@ import {
|
|||
FOLLOW_REQUEST_REJECT_SUCCESS,
|
||||
PINNED_ACCOUNTS_FETCH_SUCCESS,
|
||||
BIRTHDAY_REMINDERS_FETCH_SUCCESS,
|
||||
} from '../actions/accounts';
|
||||
} from 'soapbox/actions/accounts';
|
||||
import {
|
||||
BLOCKS_FETCH_SUCCESS,
|
||||
BLOCKS_EXPAND_SUCCESS,
|
||||
} from '../actions/blocks';
|
||||
} from 'soapbox/actions/blocks';
|
||||
import {
|
||||
DIRECTORY_FETCH_REQUEST,
|
||||
DIRECTORY_FETCH_SUCCESS,
|
||||
|
@ -28,22 +28,30 @@ import {
|
|||
DIRECTORY_EXPAND_REQUEST,
|
||||
DIRECTORY_EXPAND_SUCCESS,
|
||||
DIRECTORY_EXPAND_FAIL,
|
||||
} from '../actions/directory';
|
||||
} from 'soapbox/actions/directory';
|
||||
import {
|
||||
EVENT_PARTICIPATIONS_EXPAND_SUCCESS,
|
||||
EVENT_PARTICIPATIONS_FETCH_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS,
|
||||
EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS,
|
||||
} from 'soapbox/actions/events';
|
||||
import {
|
||||
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||
} from '../actions/familiar-followers';
|
||||
} from 'soapbox/actions/familiar-followers';
|
||||
import {
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_SUCCESS,
|
||||
REACTIONS_FETCH_SUCCESS,
|
||||
} from '../actions/interactions';
|
||||
} from 'soapbox/actions/interactions';
|
||||
import {
|
||||
MUTES_FETCH_SUCCESS,
|
||||
MUTES_EXPAND_SUCCESS,
|
||||
} from '../actions/mutes';
|
||||
} from 'soapbox/actions/mutes';
|
||||
import {
|
||||
NOTIFICATIONS_UPDATE,
|
||||
} from '../actions/notifications';
|
||||
} from 'soapbox/actions/notifications';
|
||||
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -65,6 +73,17 @@ const ReactionListRecord = ImmutableRecord({
|
|||
isLoading: false,
|
||||
});
|
||||
|
||||
export const ParticipationRequestRecord = ImmutableRecord({
|
||||
account: '',
|
||||
participation_message: null as string | null,
|
||||
});
|
||||
|
||||
const ParticipationRequestListRecord = ImmutableRecord({
|
||||
next: null as string | null,
|
||||
items: ImmutableOrderedSet<ParticipationRequest>(),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
export const ReducerRecord = ImmutableRecord({
|
||||
followers: ImmutableMap<string, List>(),
|
||||
following: ImmutableMap<string, List>(),
|
||||
|
@ -78,14 +97,18 @@ export const ReducerRecord = ImmutableRecord({
|
|||
pinned: ImmutableMap<string, List>(),
|
||||
birthday_reminders: ImmutableMap<string, List>(),
|
||||
familiar_followers: ImmutableMap<string, List>(),
|
||||
event_participations: ImmutableMap<string, List>(),
|
||||
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
export type List = ReturnType<typeof ListRecord>;
|
||||
type Reaction = ReturnType<typeof ReactionRecord>;
|
||||
type ReactionList = ReturnType<typeof ReactionListRecord>;
|
||||
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
|
||||
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
|
||||
type Items = ImmutableOrderedSet<string>;
|
||||
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers', string];
|
||||
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests', string];
|
||||
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
|
||||
|
||||
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
|
||||
|
@ -170,6 +193,33 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
|
|||
return normalizeList(state, ['birthday_reminders', action.id], action.accounts, action.next);
|
||||
case FAMILIAR_FOLLOWERS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['familiar_followers', action.id], action.accounts, action.next);
|
||||
case EVENT_PARTICIPATIONS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['event_participations', action.id], action.accounts, action.next);
|
||||
case EVENT_PARTICIPATIONS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['event_participations', action.id], action.accounts, action.next);
|
||||
case EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS:
|
||||
return state.setIn(['event_participation_requests', action.id], ParticipationRequestListRecord({
|
||||
next: action.next,
|
||||
items: ImmutableOrderedSet(action.participations.map(({ account, participation_message }: APIEntity) => ParticipationRequestRecord({
|
||||
account: account.id,
|
||||
participation_message,
|
||||
}))),
|
||||
}));
|
||||
case EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS:
|
||||
return state.updateIn(
|
||||
['event_participation_requests', action.id, 'items'],
|
||||
(items) => (items as ImmutableOrderedSet<ParticipationRequest>)
|
||||
.union(action.participations.map(({ account, participation_message }: APIEntity) => ParticipationRequestRecord({
|
||||
account: account.id,
|
||||
participation_message,
|
||||
}))),
|
||||
);
|
||||
case EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS:
|
||||
case EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS:
|
||||
return state.updateIn(
|
||||
['event_participation_requests', action.id, 'items'],
|
||||
items => (items as ImmutableOrderedSet<ParticipationRequest>).filter(({ account }) => account !== action.accountId),
|
||||
);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
HistoryRecord,
|
||||
InstanceRecord,
|
||||
ListRecord,
|
||||
LocationRecord,
|
||||
MentionRecord,
|
||||
NotificationRecord,
|
||||
PollRecord,
|
||||
|
@ -40,6 +41,7 @@ type Filter = ReturnType<typeof FilterRecord>;
|
|||
type History = ReturnType<typeof HistoryRecord>;
|
||||
type Instance = ReturnType<typeof InstanceRecord>;
|
||||
type List = ReturnType<typeof ListRecord>;
|
||||
type Location = ReturnType<typeof LocationRecord>;
|
||||
type Mention = ReturnType<typeof MentionRecord>;
|
||||
type Notification = ReturnType<typeof NotificationRecord>;
|
||||
type Poll = ReturnType<typeof PollRecord>;
|
||||
|
@ -80,6 +82,7 @@ export {
|
|||
History,
|
||||
Instance,
|
||||
List,
|
||||
Location,
|
||||
Mention,
|
||||
Notification,
|
||||
Poll,
|
||||
|
|
|
@ -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();
|
||||
};
|
|
@ -280,6 +280,22 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
ethereumLogin: v.software === MITRA,
|
||||
|
||||
/**
|
||||
* Ability to create and perform actions on events.
|
||||
* @see POST /api/v1/pleroma/events
|
||||
* @see GET /api/v1/pleroma/events/joined_events
|
||||
* @see PUT /api/v1/pleroma/events/:id
|
||||
* @see GET /api/v1/pleroma/events/:id/participations
|
||||
* @see GET /api/v1/pleroma/events/:id/participation_requests
|
||||
* @see POST /api/v1/pleroma/events/:id/participation_requests/:participant_id/authorize
|
||||
* @see POST /api/v1/pleroma/events/:id/participation_requests/:participant_id/reject
|
||||
* @see POST /api/v1/pleroma/events/:id/join
|
||||
* @see POST /api/v1/pleroma/events/:id/leave
|
||||
* @see GET /api/v1/pleroma/events/:id/ics
|
||||
* @see GET /api/v1/pleroma/search/location
|
||||
*/
|
||||
events: v.software === PLEROMA && v.build === REBASED && gte(v.version, '2.4.50'),
|
||||
|
||||
/**
|
||||
* Ability to address recipients of a status explicitly (with `to`).
|
||||
* @see POST /api/v1/statuses
|
||||
|
|
|
@ -12,6 +12,9 @@ const NOTIFICATION_TYPES = [
|
|||
'pleroma:emoji_reaction',
|
||||
'user_approved',
|
||||
'update',
|
||||
'pleroma:event_reminder',
|
||||
'pleroma:participation_request',
|
||||
'pleroma:participation_accepted',
|
||||
] as const;
|
||||
|
||||
/** Notification types to exclude from the "All" filter by default. */
|
||||
|
|
|
@ -100,7 +100,7 @@ noscript {
|
|||
}
|
||||
|
||||
.emojione {
|
||||
@apply w-5 h-5 -mt-[3px] inline;
|
||||
@apply w-4 h-4 -mt-[0.2ex] mb-[0.2ex] inline-block align-middle object-contain;
|
||||
}
|
||||
|
||||
// Virtuoso empty placeholder fix.
|
||||
|
|
|
@ -113,9 +113,3 @@ a.button {
|
|||
margin-right: 0.6em;
|
||||
}
|
||||
}
|
||||
|
||||
.button--welcome {
|
||||
.emojione {
|
||||
margin: -1px 6px 0 -4px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,12 +8,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
.emojione {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
a.account__display-name {
|
||||
&:hover strong {
|
||||
text-decoration: underline;
|
||||
|
|
|
@ -155,20 +155,6 @@
|
|||
transition: height 0.4s ease, opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.emojione {
|
||||
display: inline-block;
|
||||
font-size: inherit;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
margin: -0.2ex 0.15em 0.2ex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
img {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.image-loader {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
Ładowanie…
Reference in New Issue