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_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||||
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||||
const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||||
|
const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY';
|
||||||
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||||
const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
|
const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
|
||||||
const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
|
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 {
|
export {
|
||||||
COMPOSE_CHANGE,
|
COMPOSE_CHANGE,
|
||||||
COMPOSE_SUBMIT_REQUEST,
|
COMPOSE_SUBMIT_REQUEST,
|
||||||
|
@ -720,6 +736,7 @@ export {
|
||||||
COMPOSE_SUBMIT_FAIL,
|
COMPOSE_SUBMIT_FAIL,
|
||||||
COMPOSE_REPLY,
|
COMPOSE_REPLY,
|
||||||
COMPOSE_REPLY_CANCEL,
|
COMPOSE_REPLY_CANCEL,
|
||||||
|
COMPOSE_EVENT_REPLY,
|
||||||
COMPOSE_QUOTE,
|
COMPOSE_QUOTE,
|
||||||
COMPOSE_QUOTE_CANCEL,
|
COMPOSE_QUOTE_CANCEL,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
|
@ -806,4 +823,5 @@ export {
|
||||||
openComposeWithText,
|
openComposeWithText,
|
||||||
addToMentions,
|
addToMentions,
|
||||||
removeFromMentions,
|
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,
|
withRelationship?: boolean,
|
||||||
showEdit?: boolean,
|
showEdit?: boolean,
|
||||||
emoji?: string,
|
emoji?: string,
|
||||||
|
note?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Account = ({
|
const Account = ({
|
||||||
|
@ -92,6 +93,7 @@ const Account = ({
|
||||||
withRelationship = true,
|
withRelationship = true,
|
||||||
showEdit = false,
|
showEdit = false,
|
||||||
emoji,
|
emoji,
|
||||||
|
note,
|
||||||
}: IAccount) => {
|
}: IAccount) => {
|
||||||
const overflowRef = React.useRef<HTMLDivElement>(null);
|
const overflowRef = React.useRef<HTMLDivElement>(null);
|
||||||
const actionRef = React.useRef<HTMLDivElement>(null);
|
const actionRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
@ -169,7 +171,7 @@ const Account = ({
|
||||||
return (
|
return (
|
||||||
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
|
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
|
||||||
<HStack alignItems={actionAlignment} justifyContent='between'>
|
<HStack alignItems={actionAlignment} justifyContent='between'>
|
||||||
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
|
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
|
||||||
<ProfilePopper
|
<ProfilePopper
|
||||||
condition={showProfileHoverCard}
|
condition={showProfileHoverCard}
|
||||||
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||||
|
@ -212,7 +214,7 @@ const Account = ({
|
||||||
</LinkEl>
|
</LinkEl>
|
||||||
</ProfilePopper>
|
</ProfilePopper>
|
||||||
|
|
||||||
<Stack space={withAccountNote ? 1 : 0}>
|
<Stack space={withAccountNote || note ? 1 : 0}>
|
||||||
<HStack alignItems='center' space={1} style={style}>
|
<HStack alignItems='center' space={1} style={style}>
|
||||||
<Text theme='muted' size='sm' truncate>@{username}</Text>
|
<Text theme='muted' size='sm' truncate>@{username}</Text>
|
||||||
|
|
||||||
|
@ -251,7 +253,14 @@ const Account = ({
|
||||||
) : null}
|
) : null}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{withAccountNote && (
|
{note ? (
|
||||||
|
<Text
|
||||||
|
size='sm'
|
||||||
|
className='mr-2'
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</Text>
|
||||||
|
) : withAccountNote && (
|
||||||
<Text
|
<Text
|
||||||
size='sm'
|
size='sm'
|
||||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||||
|
|
|
@ -61,6 +61,7 @@ export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputEl
|
||||||
maxLength?: number,
|
maxLength?: number,
|
||||||
menu?: Menu,
|
menu?: Menu,
|
||||||
resultsPosition: string,
|
resultsPosition: string,
|
||||||
|
renderSuggestion?: React.FC<{ id: string }>,
|
||||||
theme?: InputThemes,
|
theme?: InputThemes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,7 +204,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
||||||
const { selectedSuggestion } = this.state;
|
const { selectedSuggestion } = this.state;
|
||||||
let inner, key;
|
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} />;
|
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||||
key = suggestion.id;
|
key = suggestion.id;
|
||||||
} else if (suggestion[0] === '#') {
|
} 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 classNames from 'clsx';
|
||||||
import { debounce } from 'lodash';
|
import debounce from 'lodash/debounce';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
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 */
|
/* Emojis */
|
||||||
[data-markup] img.emojione {
|
[data-markup] img.emojione {
|
||||||
@apply w-5 h-5;
|
@apply w-5 h-5 m-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide Markdown images (Pleroma) */
|
/* Hide Markdown images (Pleroma) */
|
||||||
|
|
|
@ -5,15 +5,18 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { cancelReplyCompose } from 'soapbox/actions/compose';
|
import { cancelReplyCompose } from 'soapbox/actions/compose';
|
||||||
|
import { cancelEventCompose } from 'soapbox/actions/events';
|
||||||
import { openModal, closeModal } from 'soapbox/actions/modals';
|
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 { UnregisterCallback } from 'history';
|
||||||
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
|
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
|
||||||
import type { ReducerCompose } from 'soapbox/reducers/compose';
|
import type { ReducerCompose } from 'soapbox/reducers/compose';
|
||||||
|
import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
|
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
|
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
|
||||||
|
@ -25,6 +28,15 @@ export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>)
|
||||||
].some(check => check === true);
|
].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 {
|
interface IModalRoot {
|
||||||
onCancel?: () => void,
|
onCancel?: () => void,
|
||||||
onClose: (type?: ModalType) => void,
|
onClose: (type?: ModalType) => void,
|
||||||
|
@ -46,8 +58,6 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||||
const prevChildren = usePrevious(children);
|
const prevChildren = usePrevious(children);
|
||||||
const prevType = usePrevious(type);
|
const prevType = usePrevious(type);
|
||||||
|
|
||||||
const isEditing = useAppSelector(state => state.compose.get('compose-modal')?.id !== null);
|
|
||||||
|
|
||||||
const visible = !!children;
|
const visible = !!children;
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
@ -58,13 +68,20 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||||
|
|
||||||
const handleOnClose = () => {
|
const handleOnClose = () => {
|
||||||
dispatch((_, getState) => {
|
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') {
|
if (hasComposeContent && type === 'COMPOSE') {
|
||||||
|
const isEditing = compose!.id !== null;
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
icon: require('@tabler/icons/trash.svg'),
|
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' />,
|
heading: isEditing
|
||||||
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?' />,
|
? <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),
|
confirm: intl.formatMessage(messages.confirm),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
dispatch(closeModal('COMPOSE'));
|
dispatch(closeModal('COMPOSE'));
|
||||||
|
@ -74,7 +91,26 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
||||||
dispatch(closeModal('CONFIRM'));
|
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'));
|
dispatch(closeModal('CONFIRM'));
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
|
@ -9,6 +9,7 @@ import AccountContainer from 'soapbox/containers/account-container';
|
||||||
import { useSettings } from 'soapbox/hooks';
|
import { useSettings } from 'soapbox/hooks';
|
||||||
import { defaultMediaVisibility } from 'soapbox/utils/status';
|
import { defaultMediaVisibility } from 'soapbox/utils/status';
|
||||||
|
|
||||||
|
import EventPreview from './event-preview';
|
||||||
import OutlineBox from './outline-box';
|
import OutlineBox from './outline-box';
|
||||||
import StatusContent from './status-content';
|
import StatusContent from './status-content';
|
||||||
import StatusReplyMentions from './status-reply-mentions';
|
import StatusReplyMentions from './status-reply-mentions';
|
||||||
|
@ -112,35 +113,37 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
|
||||||
|
|
||||||
<StatusReplyMentions status={status} hoverable={false} />
|
<StatusReplyMentions status={status} hoverable={false} />
|
||||||
|
|
||||||
<Stack
|
{status.event ? <EventPreview status={status} hideAction /> : (
|
||||||
className='relative z-0'
|
<Stack
|
||||||
style={{ minHeight: status.hidden ? Math.max(minHeight, 208) + 12 : undefined }}
|
className='relative z-0'
|
||||||
>
|
style={{ minHeight: status.hidden ? Math.max(minHeight, 208) + 12 : undefined }}
|
||||||
{(status.hidden) && (
|
>
|
||||||
<SensitiveContentOverlay
|
{(status.hidden) && (
|
||||||
status={status}
|
<SensitiveContentOverlay
|
||||||
visible={showMedia}
|
|
||||||
onToggleVisibility={handleToggleMediaVisibility}
|
|
||||||
ref={overlay}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack space={4}>
|
|
||||||
<StatusContent
|
|
||||||
status={status}
|
|
||||||
collapsable
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(status.card || status.media_attachments.size > 0) && (
|
|
||||||
<StatusMedia
|
|
||||||
status={status}
|
status={status}
|
||||||
muted={compose}
|
visible={showMedia}
|
||||||
showMedia={showMedia}
|
|
||||||
onToggleVisibility={handleToggleMediaVisibility}
|
onToggleVisibility={handleToggleMediaVisibility}
|
||||||
|
ref={overlay}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Stack space={4}>
|
||||||
|
<StatusContent
|
||||||
|
status={status}
|
||||||
|
collapsable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(status.card || status.media_attachments.size > 0) && (
|
||||||
|
<StatusMedia
|
||||||
|
status={status}
|
||||||
|
muted={compose}
|
||||||
|
showMedia={showMedia}
|
||||||
|
onToggleVisibility={handleToggleMediaVisibility}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</OutlineBox>
|
</OutlineBox>
|
||||||
);
|
);
|
||||||
|
|
|
@ -34,6 +34,7 @@ const messages = defineMessages({
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||||
|
events: { id: 'column.events', defaultMessage: 'Events' },
|
||||||
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
||||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||||
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
|
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') && (
|
{settings.get('isDeveloper') && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to='/developers'
|
to='/developers'
|
||||||
|
|
|
@ -13,6 +13,7 @@ const messages = defineMessages({
|
||||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||||
|
events: { id: 'column.events', defaultMessage: 'Events' },
|
||||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
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')) {
|
if (settings.get('isDeveloper')) {
|
||||||
menu.push({
|
menu.push({
|
||||||
to: '/developers',
|
to: '/developers',
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
||||||
import { showAlertForError } from 'soapbox/actions/alerts';
|
import { showAlertForError } from 'soapbox/actions/alerts';
|
||||||
import { launchChat } from 'soapbox/actions/chats';
|
import { launchChat } from 'soapbox/actions/chats';
|
||||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
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 { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||||
|
@ -203,7 +204,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditClick: React.EventHandler<React.MouseEvent> = () => {
|
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) => {
|
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
|
|
|
@ -43,7 +43,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
||||||
const size = status.media_attachments.size;
|
const size = status.media_attachments.size;
|
||||||
const firstAttachment = status.media_attachments.first();
|
const firstAttachment = status.media_attachments.first();
|
||||||
|
|
||||||
let media = null;
|
let media: JSX.Element | null = null;
|
||||||
|
|
||||||
const setRef = (c: HTMLDivElement): void => {
|
const setRef = (c: HTMLDivElement): void => {
|
||||||
if (c) {
|
if (c) {
|
||||||
|
@ -122,7 +122,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
||||||
const attachment = firstAttachment;
|
const attachment = firstAttachment;
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer} >
|
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer}>
|
||||||
{(Component: any) => (
|
{(Component: any) => (
|
||||||
<Component
|
<Component
|
||||||
src={attachment.url}
|
src={attachment.url}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import QuotedStatus from 'soapbox/features/status/containers/quoted-status-conta
|
||||||
import { useAppDispatch, useSettings } from 'soapbox/hooks';
|
import { useAppDispatch, useSettings } from 'soapbox/hooks';
|
||||||
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status';
|
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status';
|
||||||
|
|
||||||
|
import EventPreview from './event-preview';
|
||||||
import StatusActionBar from './status-action-bar';
|
import StatusActionBar from './status-action-bar';
|
||||||
import StatusContent from './status-content';
|
import StatusContent from './status-content';
|
||||||
import StatusMedia from './status-media';
|
import StatusMedia from './status-media';
|
||||||
|
@ -28,7 +29,7 @@ import type {
|
||||||
Status as StatusEntity,
|
Status as StatusEntity,
|
||||||
} from 'soapbox/types/entities';
|
} from 'soapbox/types/entities';
|
||||||
|
|
||||||
// Defined in components/scrollable_list
|
// Defined in components/scrollable-list
|
||||||
export type ScrollPosition = { height: number, top: number };
|
export type ScrollPosition = { height: number, top: number };
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -383,30 +384,32 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack space={4}>
|
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
|
||||||
<StatusContent
|
<Stack space={4}>
|
||||||
status={actualStatus}
|
<StatusContent
|
||||||
onClick={handleClick}
|
status={actualStatus}
|
||||||
collapsable
|
onClick={handleClick}
|
||||||
translatable
|
collapsable
|
||||||
/>
|
translatable
|
||||||
|
/>
|
||||||
|
|
||||||
<TranslateButton status={actualStatus} />
|
<TranslateButton status={actualStatus} />
|
||||||
|
|
||||||
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
|
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
|
||||||
<Stack space={4}>
|
<Stack space={4}>
|
||||||
<StatusMedia
|
<StatusMedia
|
||||||
status={actualStatus}
|
status={actualStatus}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
showMedia={showMedia}
|
showMedia={showMedia}
|
||||||
onToggleVisibility={handleToggleMediaVisibility}
|
onToggleVisibility={handleToggleMediaVisibility}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{quote}
|
{quote}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{(!hideActionBar && !isUnderReview) && (
|
{(!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>
|
<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. */
|
/** A card's body. */
|
||||||
const CardBody: React.FC = ({ children }): JSX.Element => (
|
const CardBody: React.FC<ICardBody> = ({ className, children }): JSX.Element => (
|
||||||
<div data-testid='card-body'>{children}</div>
|
<div data-testid='card-body' className={className}>{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export { Card, CardHeader, CardTitle, CardBody };
|
export { Card, CardHeader, CardTitle, CardBody };
|
||||||
|
|
|
@ -40,7 +40,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
|
||||||
/** Right sidebar container in the UI. */
|
/** Right sidebar container in the UI. */
|
||||||
const Aside: React.FC = ({ children }) => (
|
const Aside: React.FC = ({ children }) => (
|
||||||
<aside className='hidden xl:block xl:col-span-3'>
|
<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}
|
{children}
|
||||||
</StickyBox>
|
</StickyBox>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
@ -3,8 +3,8 @@ import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import Button from '../button/button';
|
import Button from '../button/button';
|
||||||
|
import HStack from '../hstack/hstack';
|
||||||
import IconButton from '../icon-button/icon-button';
|
import IconButton from '../icon-button/icon-button';
|
||||||
import Stack from '../stack/stack';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
|
@ -115,7 +115,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confirmationAction && (
|
{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'>
|
<div className='flex-grow'>
|
||||||
{cancelAction && (
|
{cancelAction && (
|
||||||
<Button
|
<Button
|
||||||
|
@ -127,7 +127,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stack space={2}>
|
<HStack space={2}>
|
||||||
{secondaryAction && (
|
{secondaryAction && (
|
||||||
<Button
|
<Button
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
|
@ -146,8 +146,8 @@ const Modal: React.FC<IModal> = ({
|
||||||
>
|
>
|
||||||
{confirmationText}
|
{confirmationText}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</HStack>
|
||||||
</div>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -10,6 +10,7 @@ const spaces = {
|
||||||
3: 'space-y-3',
|
3: 'space-y-3',
|
||||||
4: 'space-y-4',
|
4: 'space-y-4',
|
||||||
5: 'space-y-5',
|
5: 'space-y-5',
|
||||||
|
6: 'space-y-6',
|
||||||
10: 'space-y-10',
|
10: 'space-y-10',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ const alignItemsOptions = {
|
||||||
bottom: 'items-end',
|
bottom: 'items-end',
|
||||||
center: 'items-center',
|
center: 'items-center',
|
||||||
start: 'items-start',
|
start: 'items-start',
|
||||||
|
end: 'items-end',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React from 'react';
|
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. */
|
/** Put the cursor into the input on mount. */
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
/** The initial text in the input. */
|
/** The initial text in the input. */
|
||||||
|
|
|
@ -6,25 +6,12 @@ import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/
|
||||||
import { Text } from 'soapbox/components/ui';
|
import { Text } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
||||||
import sourceCode from 'soapbox/utils/code';
|
import sourceCode from 'soapbox/utils/code';
|
||||||
|
import { download } from 'soapbox/utils/download';
|
||||||
import { parseVersion } from 'soapbox/utils/features';
|
import { parseVersion } from 'soapbox/utils/features';
|
||||||
import { isNumber } from 'soapbox/utils/numbers';
|
import { isNumber } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
import RegistrationModePicker from '../components/registration-mode-picker';
|
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 Dashboard: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
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 { Link, useHistory } from 'react-router-dom';
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
|
|
||||||
|
@ -47,7 +47,8 @@ const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u20
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
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)' },
|
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
|
||||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||||
|
@ -61,9 +62,10 @@ interface IComposeForm<ID extends string> {
|
||||||
shouldCondense?: boolean,
|
shouldCondense?: boolean,
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
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 history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
@ -240,6 +242,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
||||||
|
|
||||||
let publishText: string | JSX.Element = '';
|
let publishText: string | JSX.Element = '';
|
||||||
|
let textareaPlaceholder: MessageDescriptor;
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
publishText = intl.formatMessage(messages.saveChanges);
|
publishText = intl.formatMessage(messages.saveChanges);
|
||||||
|
@ -265,9 +268,17 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
publishText = intl.formatMessage(messages.schedule);
|
publishText = intl.formatMessage(messages.schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
textareaPlaceholder = messages.eventPlaceholder;
|
||||||
|
} else if (hasPoll) {
|
||||||
|
textareaPlaceholder = messages.pollPlaceholder;
|
||||||
|
} else {
|
||||||
|
textareaPlaceholder = messages.placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||||
{scheduledStatusCount > 0 && (
|
{scheduledStatusCount > 0 && !event && (
|
||||||
<Warning
|
<Warning
|
||||||
message={(
|
message={(
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -288,13 +299,13 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
<WarningContainer composeId={id} />
|
<WarningContainer composeId={id} />
|
||||||
|
|
||||||
{!shouldCondense && <ReplyIndicatorContainer composeId={id} />}
|
{!shouldCondense && !event && <ReplyIndicatorContainer composeId={id} />}
|
||||||
|
|
||||||
{!shouldCondense && <ReplyMentions composeId={id} />}
|
{!shouldCondense && !event && <ReplyMentions composeId={id} />}
|
||||||
|
|
||||||
<AutosuggestTextarea
|
<AutosuggestTextarea
|
||||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
||||||
placeholder={intl.formatMessage(hasPoll ? messages.pollPlaceholder : messages.placeholder)}
|
placeholder={intl.formatMessage(textareaPlaceholder)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={handleChange}
|
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 { DatePicker } from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
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);
|
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 }) }}>
|
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||||
{({ scale }) => (
|
{({ scale }) => (
|
||||||
<div
|
<div
|
||||||
className={classNames('compose-form__upload-thumbnail', `${mediaType}`)}
|
className={classNames('compose-form__upload-thumbnail', mediaType)}
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
|
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'),
|
'pleroma:emoji_reaction': require('@tabler/icons/mood-happy.svg'),
|
||||||
user_approved: require('@tabler/icons/user-plus.svg'),
|
user_approved: require('@tabler/icons/user-plus.svg'),
|
||||||
update: require('@tabler/icons/pencil.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({
|
const nameMessage = defineMessage({
|
||||||
|
@ -107,6 +110,18 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
|
||||||
id: 'notification.update',
|
id: 'notification.update',
|
||||||
defaultMessage: '{name} edited a post you interacted with',
|
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 = (
|
const buildMessage = (
|
||||||
|
@ -297,6 +312,9 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
case 'poll':
|
case 'poll':
|
||||||
case 'update':
|
case 'update':
|
||||||
case 'pleroma:emoji_reaction':
|
case 'pleroma:emoji_reaction':
|
||||||
|
case 'pleroma:event_reminder':
|
||||||
|
case 'pleroma:participation_accepted':
|
||||||
|
case 'pleroma:participation_request':
|
||||||
return status && typeof status === 'object' ? (
|
return status && typeof status === 'object' ? (
|
||||||
<Status
|
<Status
|
||||||
status={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) {
|
if (video) {
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={Video} >
|
<Bundle fetchComponent={Video}>
|
||||||
{(Component: any) => (
|
{(Component: any) => (
|
||||||
<Component
|
<Component
|
||||||
preview={video.preview_url}
|
preview={video.preview_url}
|
||||||
|
@ -58,7 +58,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
|
||||||
|
|
||||||
if (audio) {
|
if (audio) {
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={Audio} >
|
<Bundle fetchComponent={Audio}>
|
||||||
{(Component: any) => (
|
{(Component: any) => (
|
||||||
<Component
|
<Component
|
||||||
src={audio.url}
|
src={audio.url}
|
||||||
|
@ -73,7 +73,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={MediaGallery} >
|
<Bundle fetchComponent={MediaGallery}>
|
||||||
{(Component: any) => <Component media={status.media_attachments} sensitive={status.sensitive} height={110} onOpenMedia={noop} />}
|
{(Component: any) => <Component media={status.media_attachments} sensitive={status.sensitive} height={110} onOpenMedia={noop} />}
|
||||||
</Bundle>
|
</Bundle>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import classNames from 'clsx';
|
import classNames from 'clsx';
|
||||||
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
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 React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -82,7 +82,7 @@ const getAncestorsIds = createSelector([
|
||||||
return ancestorsIds;
|
return ancestorsIds;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getDescendantsIds = createSelector([
|
export const getDescendantsIds = createSelector([
|
||||||
(_: RootState, statusId: string) => statusId,
|
(_: RootState, statusId: string) => statusId,
|
||||||
(state: RootState) => state.contexts.replies,
|
(state: RootState) => state.contexts.replies,
|
||||||
], (statusId, contextReplies) => {
|
], (statusId, contextReplies) => {
|
||||||
|
@ -425,6 +425,12 @@ const Thread: React.FC<IThread> = (props) => {
|
||||||
const hasAncestors = ancestorsIds.size > 0;
|
const hasAncestors = ancestorsIds.size > 0;
|
||||||
const hasDescendants = descendantsIds.size > 0;
|
const hasDescendants = descendantsIds.size > 0;
|
||||||
|
|
||||||
|
if (status?.event) {
|
||||||
|
return (
|
||||||
|
<Redirect to={`/@${status.getIn(['account', 'acct'])}/events/${status.id}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!status && isLoaded) {
|
if (!status && isLoaded) {
|
||||||
return (
|
return (
|
||||||
<MissingIndicator />
|
<MissingIndicator />
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import IconButton from 'soapbox/components/icon-button';
|
import { Modal } from 'soapbox/components/ui';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
|
||||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
||||||
});
|
});
|
||||||
|
@ -22,23 +22,13 @@ const BundleModalError: React.FC<IBundleModalError> = ({ onRetry, onClose }) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal error-modal'>
|
<Modal
|
||||||
<div className='error-modal__body'>
|
title={intl.formatMessage(messages.error)}
|
||||||
<IconButton title={intl.formatMessage(messages.retry)} icon='refresh' onClick={handleRetry} size={64} />
|
confirmationAction={onClose}
|
||||||
{intl.formatMessage(messages.error)}
|
confirmationText={intl.formatMessage(messages.close)}
|
||||||
</div>
|
secondaryAction={handleRetry}
|
||||||
|
secondaryText={intl.formatMessage(messages.retry)}
|
||||||
<div className='error-modal__footer'>
|
/>
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className='error-modal__nav'
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.close)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,9 @@ const LinkFooter: React.FC = (): JSX.Element => {
|
||||||
{features.profileDirectory && (
|
{features.profileDirectory && (
|
||||||
<FooterLink to='/directory'><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></FooterLink>
|
<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='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
|
||||||
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
|
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
|
||||||
{features.filters && (
|
{features.filters && (
|
||||||
|
|
|
@ -30,7 +30,10 @@ import {
|
||||||
CompareHistoryModal,
|
CompareHistoryModal,
|
||||||
VerifySmsModal,
|
VerifySmsModal,
|
||||||
FamiliarFollowersModal,
|
FamiliarFollowersModal,
|
||||||
|
ComposeEventModal,
|
||||||
|
JoinEventModal,
|
||||||
AccountModerationModal,
|
AccountModerationModal,
|
||||||
|
EventParticipantsModal,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
import BundleContainer from '../containers/bundle-container';
|
import BundleContainer from '../containers/bundle-container';
|
||||||
|
@ -68,7 +71,10 @@ const MODAL_COMPONENTS = {
|
||||||
'COMPARE_HISTORY': CompareHistoryModal,
|
'COMPARE_HISTORY': CompareHistoryModal,
|
||||||
'VERIFY_SMS': VerifySmsModal,
|
'VERIFY_SMS': VerifySmsModal,
|
||||||
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
|
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
|
||||||
|
'COMPOSE_EVENT': ComposeEventModal,
|
||||||
|
'JOIN_EVENT': JoinEventModal,
|
||||||
'ACCOUNT_MODERATION': AccountModerationModal,
|
'ACCOUNT_MODERATION': AccountModerationModal,
|
||||||
|
'EVENT_PARTICIPANTS': EventParticipantsModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModalType = keyof typeof MODAL_COMPONENTS | null;
|
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.' },
|
blankslate: { id: 'report.reason.blankslate', defaultMessage: 'You have removed all statuses from being selected.' },
|
||||||
done: { id: 'report.done', defaultMessage: 'Done' },
|
done: { id: 'report.done', defaultMessage: 'Done' },
|
||||||
next: { id: 'report.next', defaultMessage: 'Next' },
|
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' },
|
submit: { id: 'report.submit', defaultMessage: 'Submit' },
|
||||||
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
||||||
previous: { id: 'report.previous', defaultMessage: 'Previous' },
|
previous: { id: 'report.previous', defaultMessage: 'Previous' },
|
||||||
|
|
|
@ -15,7 +15,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
interface IUnauthorizedModal {
|
interface IUnauthorizedModal {
|
||||||
/** Unauthorized action type. */
|
/** Unauthorized action type. */
|
||||||
action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE',
|
action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE' | 'JOIN',
|
||||||
/** Close event handler. */
|
/** Close event handler. */
|
||||||
onClose: (modalType: string) => void,
|
onClose: (modalType: string) => void,
|
||||||
/** ActivityPub ID of the account OR status being acted upon. */
|
/** 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') {
|
} else if (action === 'POLL_VOTE') {
|
||||||
header = <FormattedMessage id='remote_interaction.poll_vote_title' defaultMessage='Vote in a poll remotely' />;
|
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' />;
|
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 (
|
return (
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { cancelReplyCompose } from 'soapbox/actions/compose';
|
import { cancelReplyCompose } from 'soapbox/actions/compose';
|
||||||
|
import { cancelEventCompose } from 'soapbox/actions/events';
|
||||||
import { closeModal } from 'soapbox/actions/modals';
|
import { closeModal } from 'soapbox/actions/modals';
|
||||||
import { cancelReport } from 'soapbox/actions/reports';
|
import { cancelReport } from 'soapbox/actions/reports';
|
||||||
|
|
||||||
|
@ -26,6 +27,9 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
|
||||||
case 'COMPOSE':
|
case 'COMPOSE':
|
||||||
dispatch(cancelReplyCompose());
|
dispatch(cancelReplyCompose());
|
||||||
break;
|
break;
|
||||||
|
case 'COMPOSE_EVENT':
|
||||||
|
dispatch(cancelEventCompose());
|
||||||
|
break;
|
||||||
case 'REPORT':
|
case 'REPORT':
|
||||||
dispatch(cancelReport());
|
dispatch(cancelReport());
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
||||||
import { fetchChats } from 'soapbox/actions/chats';
|
import { fetchChats } from 'soapbox/actions/chats';
|
||||||
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
|
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
|
||||||
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
||||||
|
import { uploadEventBanner } from 'soapbox/actions/events';
|
||||||
import { fetchFilters } from 'soapbox/actions/filters';
|
import { fetchFilters } from 'soapbox/actions/filters';
|
||||||
import { fetchMarker } from 'soapbox/actions/markers';
|
import { fetchMarker } from 'soapbox/actions/markers';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
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 { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks';
|
||||||
import AdminPage from 'soapbox/pages/admin-page';
|
import AdminPage from 'soapbox/pages/admin-page';
|
||||||
import DefaultPage from 'soapbox/pages/default-page';
|
import DefaultPage from 'soapbox/pages/default-page';
|
||||||
|
import EventPage from 'soapbox/pages/event-page';
|
||||||
import HomePage from 'soapbox/pages/home-page';
|
import HomePage from 'soapbox/pages/home-page';
|
||||||
import ProfilePage from 'soapbox/pages/profile-page';
|
import ProfilePage from 'soapbox/pages/profile-page';
|
||||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||||
|
@ -111,6 +113,9 @@ import {
|
||||||
AuthTokenList,
|
AuthTokenList,
|
||||||
Quotes,
|
Quotes,
|
||||||
ServiceWorkerInfo,
|
ServiceWorkerInfo,
|
||||||
|
EventInformation,
|
||||||
|
EventDiscussion,
|
||||||
|
Events,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { WrappedRoute } from './util/react-router-helpers';
|
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} />
|
<WrappedRoute path='/search' page={DefaultPage} component={Search} content={children} />
|
||||||
{features.suggestions && <WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} 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.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' exact page={DefaultPage} component={ChatIndex} content={children} />}
|
||||||
{features.chats && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} 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/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' publicRoute exact page={StatusPage} component={Status} content={children} />
|
||||||
<WrappedRoute path='/@:username/posts/:statusId/quotes' publicRoute page={StatusPage} component={Quotes} 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' />
|
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||||
|
|
||||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
<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) {
|
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
|
||||||
const modals = getState().modals;
|
const modals = getState().modals;
|
||||||
const isModalOpen = modals.last()?.modalType === 'COMPOSE';
|
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() {
|
export function Quotes() {
|
||||||
return import(/*webpackChunkName: "features/quotes" */'../../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.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Опитай отново",
|
"bundle_column_error.retry": "Опитай отново",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Klask endro",
|
"bundle_column_error.retry": "Klask endro",
|
||||||
"bundle_column_error.title": "Fazi rouedad",
|
"bundle_column_error.title": "Fazi rouedad",
|
||||||
"bundle_modal_error.close": "Serriñ",
|
"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",
|
"bundle_modal_error.retry": "Klask endro",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Try again",
|
"bundle_column_error.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Try again",
|
"bundle_column_error.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Try again",
|
"bundle_column_error.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Try again",
|
"bundle_column_error.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Try again",
|
"bundle_column_error.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Try again",
|
"bundle_column_error.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -183,7 +183,7 @@
|
||||||
"bundle_column_error.retry": "Try again",
|
"bundle_column_error.retry": "Try again",
|
||||||
"bundle_column_error.title": "Network error",
|
"bundle_column_error.title": "Network error",
|
||||||
"bundle_modal_error.close": "Close",
|
"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",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"card.back.label": "Back",
|
"card.back.label": "Back",
|
||||||
"chat_box.actions.send": "Send",
|
"chat_box.actions.send": "Send",
|
||||||
|
|
|
@ -24,7 +24,7 @@ export const AdminReportRecord = ImmutableRecord({
|
||||||
rules: ImmutableList<string>(),
|
rules: ImmutableList<string>(),
|
||||||
statuses: ImmutableList<EmbeddedEntity<Status>>(),
|
statuses: ImmutableList<EmbeddedEntity<Status>>(),
|
||||||
target_account: null as EmbeddedEntity<Account | ReducerAccount>,
|
target_account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizePleromaReport = (report: ImmutableMap<string, any>) => {
|
const normalizePleromaReport = (report: ImmutableMap<string, any>) => {
|
||||||
|
|
|
@ -12,6 +12,7 @@ export { FilterRecord, normalizeFilter } from './filter';
|
||||||
export { HistoryRecord, normalizeHistory } from './history';
|
export { HistoryRecord, normalizeHistory } from './history';
|
||||||
export { InstanceRecord, normalizeInstance } from './instance';
|
export { InstanceRecord, normalizeInstance } from './instance';
|
||||||
export { ListRecord, normalizeList } from './list';
|
export { ListRecord, normalizeList } from './list';
|
||||||
|
export { LocationRecord, normalizeLocation } from './location';
|
||||||
export { MentionRecord, normalizeMention } from './mention';
|
export { MentionRecord, normalizeMention } from './mention';
|
||||||
export { NotificationRecord, normalizeNotification } from './notification';
|
export { NotificationRecord, normalizeNotification } from './notification';
|
||||||
export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
|
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();
|
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/
|
// https://docs.joinmastodon.org/entities/status/
|
||||||
export const StatusRecord = ImmutableRecord({
|
export const StatusRecord = ImmutableRecord({
|
||||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||||
|
@ -66,6 +81,7 @@ export const StatusRecord = ImmutableRecord({
|
||||||
uri: '',
|
uri: '',
|
||||||
url: '',
|
url: '',
|
||||||
visibility: 'public' as StatusVisibility,
|
visibility: 'public' as StatusVisibility,
|
||||||
|
event: null as ReturnType<typeof EventRecord> | null,
|
||||||
|
|
||||||
// Internal fields
|
// Internal fields
|
||||||
contentHtml: '',
|
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) => {
|
export const normalizeStatus = (status: Record<string, any>, depth = 0) => {
|
||||||
return StatusRecord(
|
return StatusRecord(
|
||||||
ImmutableMap(fromJS(status)).withMutations(status => {
|
ImmutableMap(fromJS(status)).withMutations(status => {
|
||||||
|
@ -225,6 +268,7 @@ export const normalizeStatus = (status: Record<string, any>, depth = 0) => {
|
||||||
fixSensitivity(status);
|
fixSensitivity(status);
|
||||||
normalizeReblogQuote(status, depth);
|
normalizeReblogQuote(status, depth);
|
||||||
addInternalFields(status);
|
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', () => {
|
it('uses \'public\' scope as default', () => {
|
||||||
const action = {
|
const action = {
|
||||||
type: actions.COMPOSE_REPLY,
|
type: actions.COMPOSE_REPLY,
|
||||||
|
id: 'compose-modal',
|
||||||
status: ImmutableRecord({})(),
|
status: ImmutableRecord({})(),
|
||||||
account: ImmutableRecord({})(),
|
account: ImmutableRecord({})(),
|
||||||
};
|
};
|
||||||
|
@ -101,6 +102,7 @@ describe('compose reducer', () => {
|
||||||
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
||||||
const action = {
|
const action = {
|
||||||
type: actions.COMPOSE_REPLY,
|
type: actions.COMPOSE_REPLY,
|
||||||
|
id: 'compose-modal',
|
||||||
status: ImmutableRecord({ visibility: 'direct' })(),
|
status: ImmutableRecord({ visibility: 'direct' })(),
|
||||||
account: ImmutableRecord({})(),
|
account: ImmutableRecord({})(),
|
||||||
};
|
};
|
||||||
|
@ -111,6 +113,7 @@ describe('compose reducer', () => {
|
||||||
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
||||||
const action = {
|
const action = {
|
||||||
type: actions.COMPOSE_REPLY,
|
type: actions.COMPOSE_REPLY,
|
||||||
|
id: 'compose-modal',
|
||||||
status: ImmutableRecord({ visibility: 'private' })(),
|
status: ImmutableRecord({ visibility: 'private' })(),
|
||||||
account: ImmutableRecord({})(),
|
account: ImmutableRecord({})(),
|
||||||
};
|
};
|
||||||
|
@ -121,6 +124,7 @@ describe('compose reducer', () => {
|
||||||
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
|
||||||
const action = {
|
const action = {
|
||||||
type: actions.COMPOSE_REPLY,
|
type: actions.COMPOSE_REPLY,
|
||||||
|
id: 'compose-modal',
|
||||||
status: ImmutableRecord({ visibility: 'unlisted' })(),
|
status: ImmutableRecord({ visibility: 'unlisted' })(),
|
||||||
account: ImmutableRecord({})(),
|
account: ImmutableRecord({})(),
|
||||||
};
|
};
|
||||||
|
@ -131,6 +135,7 @@ describe('compose reducer', () => {
|
||||||
const state = initialState.set('default', ReducerCompose({ privacy: 'private' }));
|
const state = initialState.set('default', ReducerCompose({ privacy: 'private' }));
|
||||||
const action = {
|
const action = {
|
||||||
type: actions.COMPOSE_REPLY,
|
type: actions.COMPOSE_REPLY,
|
||||||
|
id: 'compose-modal',
|
||||||
status: ImmutableRecord({ visibility: 'public' })(),
|
status: ImmutableRecord({ visibility: 'public' })(),
|
||||||
account: ImmutableRecord({})(),
|
account: ImmutableRecord({})(),
|
||||||
};
|
};
|
||||||
|
@ -141,6 +146,7 @@ describe('compose reducer', () => {
|
||||||
const state = initialState.set('default', ReducerCompose({ privacy: 'unlisted' }));
|
const state = initialState.set('default', ReducerCompose({ privacy: 'unlisted' }));
|
||||||
const action = {
|
const action = {
|
||||||
type: actions.COMPOSE_REPLY,
|
type: actions.COMPOSE_REPLY,
|
||||||
|
id: 'compose-modal',
|
||||||
status: ImmutableRecord({ visibility: 'public' })(),
|
status: ImmutableRecord({ visibility: 'public' })(),
|
||||||
account: ImmutableRecord({})(),
|
account: ImmutableRecord({})(),
|
||||||
};
|
};
|
||||||
|
|
|
@ -27,6 +27,18 @@ describe('status_lists reducer', () => {
|
||||||
isLoading: null,
|
isLoading: null,
|
||||||
items: [],
|
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_ADD_TO_MENTIONS,
|
||||||
COMPOSE_REMOVE_FROM_MENTIONS,
|
COMPOSE_REMOVE_FROM_MENTIONS,
|
||||||
COMPOSE_SET_STATUS,
|
COMPOSE_SET_STATUS,
|
||||||
|
COMPOSE_EVENT_REPLY,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me';
|
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me';
|
||||||
import { SETTING_CHANGE, FE_NAME } from '../actions/settings';
|
import { SETTING_CHANGE, FE_NAME } from '../actions/settings';
|
||||||
|
@ -305,7 +306,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
||||||
case COMPOSE_COMPOSING_CHANGE:
|
case COMPOSE_COMPOSING_CHANGE:
|
||||||
return updateCompose(state, action.id, compose => compose.set('is_composing', action.value));
|
return updateCompose(state, action.id, compose => compose.set('is_composing', action.value));
|
||||||
case COMPOSE_REPLY:
|
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')!;
|
const defaultCompose = state.get('default')!;
|
||||||
|
|
||||||
map.set('in_reply_to', action.status.get('id'));
|
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('idempotencyKey', uuid());
|
||||||
map.set('content_type', defaultCompose.content_type);
|
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:
|
case COMPOSE_QUOTE:
|
||||||
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
|
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
|
||||||
const author = action.status.getIn(['account', 'acct']);
|
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_QUOTE_CANCEL:
|
||||||
case COMPOSE_RESET:
|
case COMPOSE_RESET:
|
||||||
case COMPOSE_SUBMIT_SUCCESS:
|
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:
|
case COMPOSE_SUBMIT_FAIL:
|
||||||
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
|
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
|
||||||
case COMPOSE_UPLOAD_CHANGE_FAIL:
|
case COMPOSE_UPLOAD_CHANGE_FAIL:
|
||||||
|
|
|
@ -20,6 +20,7 @@ import chat_message_lists from './chat-message-lists';
|
||||||
import chat_messages from './chat-messages';
|
import chat_messages from './chat-messages';
|
||||||
import chats from './chats';
|
import chats from './chats';
|
||||||
import compose from './compose';
|
import compose from './compose';
|
||||||
|
import compose_event from './compose-event';
|
||||||
import contexts from './contexts';
|
import contexts from './contexts';
|
||||||
import conversations from './conversations';
|
import conversations from './conversations';
|
||||||
import custom_emojis from './custom-emojis';
|
import custom_emojis from './custom-emojis';
|
||||||
|
@ -31,6 +32,7 @@ import instance from './instance';
|
||||||
import listAdder from './list-adder';
|
import listAdder from './list-adder';
|
||||||
import listEditor from './list-editor';
|
import listEditor from './list-editor';
|
||||||
import lists from './lists';
|
import lists from './lists';
|
||||||
|
import locations from './locations';
|
||||||
import me from './me';
|
import me from './me';
|
||||||
import meta from './meta';
|
import meta from './meta';
|
||||||
import modals from './modals';
|
import modals from './modals';
|
||||||
|
@ -87,6 +89,7 @@ const reducers = {
|
||||||
lists,
|
lists,
|
||||||
listEditor,
|
listEditor,
|
||||||
listAdder,
|
listAdder,
|
||||||
|
locations,
|
||||||
filters,
|
filters,
|
||||||
conversations,
|
conversations,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
@ -117,6 +120,7 @@ const reducers = {
|
||||||
rules,
|
rules,
|
||||||
history,
|
history,
|
||||||
announcements,
|
announcements,
|
||||||
|
compose_event,
|
||||||
entities,
|
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_SUCCESS,
|
||||||
BOOKMARKED_STATUSES_EXPAND_FAIL,
|
BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||||
} from '../actions/bookmarks';
|
} 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 {
|
import {
|
||||||
FAVOURITED_STATUSES_FETCH_REQUEST,
|
FAVOURITED_STATUSES_FETCH_REQUEST,
|
||||||
FAVOURITED_STATUSES_FETCH_SUCCESS,
|
FAVOURITED_STATUSES_FETCH_SUCCESS,
|
||||||
|
@ -77,6 +85,8 @@ const initialState: State = ImmutableMap({
|
||||||
bookmarks: StatusListRecord(),
|
bookmarks: StatusListRecord(),
|
||||||
pins: StatusListRecord(),
|
pins: StatusListRecord(),
|
||||||
scheduled_statuses: StatusListRecord(),
|
scheduled_statuses: StatusListRecord(),
|
||||||
|
recent_events: StatusListRecord(),
|
||||||
|
joined_events: StatusListRecord(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusId = (status: string | StatusEntity) => typeof status === 'string' ? status : status.id;
|
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);
|
return normalizeList(state, `quotes:${action.statusId}`, action.statuses, action.next);
|
||||||
case STATUS_QUOTES_EXPAND_SUCCESS:
|
case STATUS_QUOTES_EXPAND_SUCCESS:
|
||||||
return appendToList(state, `quotes:${action.statusId}`, action.statuses, action.next);
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,12 @@ import {
|
||||||
EMOJI_REACT_REQUEST,
|
EMOJI_REACT_REQUEST,
|
||||||
UNEMOJI_REACT_REQUEST,
|
UNEMOJI_REACT_REQUEST,
|
||||||
} from '../actions/emoji-reacts';
|
} 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 { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||||
import {
|
import {
|
||||||
REBLOG_REQUEST,
|
REBLOG_REQUEST,
|
||||||
|
@ -281,6 +287,13 @@ export default function statuses(state = initialState, action: AnyAction): State
|
||||||
return deleteTranslation(state, action.id);
|
return deleteTranslation(state, action.id);
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
return deleteStatus(state, action.id, action.references);
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,9 @@ import {
|
||||||
TRENDING_STATUSES_FETCH_REQUEST,
|
TRENDING_STATUSES_FETCH_REQUEST,
|
||||||
TRENDING_STATUSES_FETCH_SUCCESS,
|
TRENDING_STATUSES_FETCH_SUCCESS,
|
||||||
} from 'soapbox/actions/trending-statuses';
|
} from 'soapbox/actions/trending-statuses';
|
||||||
import { APIEntity } from 'soapbox/types/entities';
|
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const ReducerRecord = ImmutableRecord({
|
const ReducerRecord = ImmutableRecord({
|
||||||
items: ImmutableOrderedSet<string>(),
|
items: ImmutableOrderedSet<string>(),
|
||||||
|
|
|
@ -16,11 +16,11 @@ import {
|
||||||
FOLLOW_REQUEST_REJECT_SUCCESS,
|
FOLLOW_REQUEST_REJECT_SUCCESS,
|
||||||
PINNED_ACCOUNTS_FETCH_SUCCESS,
|
PINNED_ACCOUNTS_FETCH_SUCCESS,
|
||||||
BIRTHDAY_REMINDERS_FETCH_SUCCESS,
|
BIRTHDAY_REMINDERS_FETCH_SUCCESS,
|
||||||
} from '../actions/accounts';
|
} from 'soapbox/actions/accounts';
|
||||||
import {
|
import {
|
||||||
BLOCKS_FETCH_SUCCESS,
|
BLOCKS_FETCH_SUCCESS,
|
||||||
BLOCKS_EXPAND_SUCCESS,
|
BLOCKS_EXPAND_SUCCESS,
|
||||||
} from '../actions/blocks';
|
} from 'soapbox/actions/blocks';
|
||||||
import {
|
import {
|
||||||
DIRECTORY_FETCH_REQUEST,
|
DIRECTORY_FETCH_REQUEST,
|
||||||
DIRECTORY_FETCH_SUCCESS,
|
DIRECTORY_FETCH_SUCCESS,
|
||||||
|
@ -28,22 +28,30 @@ import {
|
||||||
DIRECTORY_EXPAND_REQUEST,
|
DIRECTORY_EXPAND_REQUEST,
|
||||||
DIRECTORY_EXPAND_SUCCESS,
|
DIRECTORY_EXPAND_SUCCESS,
|
||||||
DIRECTORY_EXPAND_FAIL,
|
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 {
|
import {
|
||||||
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||||
} from '../actions/familiar-followers';
|
} from 'soapbox/actions/familiar-followers';
|
||||||
import {
|
import {
|
||||||
REBLOGS_FETCH_SUCCESS,
|
REBLOGS_FETCH_SUCCESS,
|
||||||
FAVOURITES_FETCH_SUCCESS,
|
FAVOURITES_FETCH_SUCCESS,
|
||||||
REACTIONS_FETCH_SUCCESS,
|
REACTIONS_FETCH_SUCCESS,
|
||||||
} from '../actions/interactions';
|
} from 'soapbox/actions/interactions';
|
||||||
import {
|
import {
|
||||||
MUTES_FETCH_SUCCESS,
|
MUTES_FETCH_SUCCESS,
|
||||||
MUTES_EXPAND_SUCCESS,
|
MUTES_EXPAND_SUCCESS,
|
||||||
} from '../actions/mutes';
|
} from 'soapbox/actions/mutes';
|
||||||
import {
|
import {
|
||||||
NOTIFICATIONS_UPDATE,
|
NOTIFICATIONS_UPDATE,
|
||||||
} from '../actions/notifications';
|
} from 'soapbox/actions/notifications';
|
||||||
|
|
||||||
import type { APIEntity } from 'soapbox/types/entities';
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
@ -65,6 +73,17 @@ const ReactionListRecord = ImmutableRecord({
|
||||||
isLoading: false,
|
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({
|
export const ReducerRecord = ImmutableRecord({
|
||||||
followers: ImmutableMap<string, List>(),
|
followers: ImmutableMap<string, List>(),
|
||||||
following: ImmutableMap<string, List>(),
|
following: ImmutableMap<string, List>(),
|
||||||
|
@ -78,14 +97,18 @@ export const ReducerRecord = ImmutableRecord({
|
||||||
pinned: ImmutableMap<string, List>(),
|
pinned: ImmutableMap<string, List>(),
|
||||||
birthday_reminders: ImmutableMap<string, List>(),
|
birthday_reminders: ImmutableMap<string, List>(),
|
||||||
familiar_followers: ImmutableMap<string, List>(),
|
familiar_followers: ImmutableMap<string, List>(),
|
||||||
|
event_participations: ImmutableMap<string, List>(),
|
||||||
|
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type State = ReturnType<typeof ReducerRecord>;
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
export type List = ReturnType<typeof ListRecord>;
|
export type List = ReturnType<typeof ListRecord>;
|
||||||
type Reaction = ReturnType<typeof ReactionRecord>;
|
type Reaction = ReturnType<typeof ReactionRecord>;
|
||||||
type ReactionList = ReturnType<typeof ReactionListRecord>;
|
type ReactionList = ReturnType<typeof ReactionListRecord>;
|
||||||
|
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
|
||||||
|
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
|
||||||
type Items = ImmutableOrderedSet<string>;
|
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'];
|
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
|
||||||
|
|
||||||
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
|
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);
|
return normalizeList(state, ['birthday_reminders', action.id], action.accounts, action.next);
|
||||||
case FAMILIAR_FOLLOWERS_FETCH_SUCCESS:
|
case FAMILIAR_FOLLOWERS_FETCH_SUCCESS:
|
||||||
return normalizeList(state, ['familiar_followers', action.id], action.accounts, action.next);
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,7 +140,7 @@ const handlePush = (event: PushEvent) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => {
|
fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => {
|
||||||
const options: ExtendedNotificationOptions = {
|
const options: ExtendedNotificationOptions = {
|
||||||
title: formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }),
|
title: formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }),
|
||||||
body: notification.status && htmlToPlainText(notification.status.content),
|
body: notification.status && htmlToPlainText(notification.status.content),
|
||||||
icon: notification.account.avatar_static,
|
icon: notification.account.avatar_static,
|
||||||
timestamp: notification.created_at && Number(new Date(notification.created_at)),
|
timestamp: notification.created_at && Number(new Date(notification.created_at)),
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
HistoryRecord,
|
HistoryRecord,
|
||||||
InstanceRecord,
|
InstanceRecord,
|
||||||
ListRecord,
|
ListRecord,
|
||||||
|
LocationRecord,
|
||||||
MentionRecord,
|
MentionRecord,
|
||||||
NotificationRecord,
|
NotificationRecord,
|
||||||
PollRecord,
|
PollRecord,
|
||||||
|
@ -40,6 +41,7 @@ type Filter = ReturnType<typeof FilterRecord>;
|
||||||
type History = ReturnType<typeof HistoryRecord>;
|
type History = ReturnType<typeof HistoryRecord>;
|
||||||
type Instance = ReturnType<typeof InstanceRecord>;
|
type Instance = ReturnType<typeof InstanceRecord>;
|
||||||
type List = ReturnType<typeof ListRecord>;
|
type List = ReturnType<typeof ListRecord>;
|
||||||
|
type Location = ReturnType<typeof LocationRecord>;
|
||||||
type Mention = ReturnType<typeof MentionRecord>;
|
type Mention = ReturnType<typeof MentionRecord>;
|
||||||
type Notification = ReturnType<typeof NotificationRecord>;
|
type Notification = ReturnType<typeof NotificationRecord>;
|
||||||
type Poll = ReturnType<typeof PollRecord>;
|
type Poll = ReturnType<typeof PollRecord>;
|
||||||
|
@ -80,6 +82,7 @@ export {
|
||||||
History,
|
History,
|
||||||
Instance,
|
Instance,
|
||||||
List,
|
List,
|
||||||
|
Location,
|
||||||
Mention,
|
Mention,
|
||||||
Notification,
|
Notification,
|
||||||
Poll,
|
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,
|
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`).
|
* Ability to address recipients of a status explicitly (with `to`).
|
||||||
* @see POST /api/v1/statuses
|
* @see POST /api/v1/statuses
|
||||||
|
|
|
@ -12,6 +12,9 @@ const NOTIFICATION_TYPES = [
|
||||||
'pleroma:emoji_reaction',
|
'pleroma:emoji_reaction',
|
||||||
'user_approved',
|
'user_approved',
|
||||||
'update',
|
'update',
|
||||||
|
'pleroma:event_reminder',
|
||||||
|
'pleroma:participation_request',
|
||||||
|
'pleroma:participation_accepted',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Notification types to exclude from the "All" filter by default. */
|
/** Notification types to exclude from the "All" filter by default. */
|
||||||
|
|
|
@ -100,7 +100,7 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
.emojione {
|
.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.
|
// Virtuoso empty placeholder fix.
|
||||||
|
|
|
@ -113,9 +113,3 @@ a.button {
|
||||||
margin-right: 0.6em;
|
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 {
|
a.account__display-name {
|
||||||
&:hover strong {
|
&:hover strong {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
|
@ -155,20 +155,6 @@
|
||||||
transition: height 0.4s ease, opacity 0.4s ease;
|
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 {
|
.image-loader {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
Ładowanie…
Reference in New Issue