Merge remote-tracking branch 'origin/develop' into entity-store

entity-store
Alex Gleason 2022-12-06 11:55:47 -06:00
commit 92b7eb0ffe
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
83 zmienionych plików z 3496 dodań i 173 usunięć

Wyświetl plik

@ -35,6 +35,7 @@ const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
const COMPOSE_REPLY = 'COMPOSE_REPLY';
const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY';
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
@ -713,6 +714,21 @@ const removeFromMentions = (composeId: string, accountId: string) =>
});
};
const eventDiscussionCompose = (composeId: string, status: Status) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const { explicitAddressing } = getFeatures(instance);
dispatch({
type: COMPOSE_EVENT_REPLY,
id: composeId,
status: status,
account: state.accounts.get(state.me),
explicitAddressing,
});
};
export {
COMPOSE_CHANGE,
COMPOSE_SUBMIT_REQUEST,
@ -720,6 +736,7 @@ export {
COMPOSE_SUBMIT_FAIL,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_EVENT_REPLY,
COMPOSE_QUOTE,
COMPOSE_QUOTE_CANCEL,
COMPOSE_DIRECT,
@ -806,4 +823,5 @@ export {
openComposeWithText,
addToMentions,
removeFromMentions,
eventDiscussionCompose,
};

Wyświetl plik

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

Wyświetl plik

@ -69,6 +69,7 @@ interface IAccount {
withRelationship?: boolean,
showEdit?: boolean,
emoji?: string,
note?: string,
}
const Account = ({
@ -92,6 +93,7 @@ const Account = ({
withRelationship = true,
showEdit = false,
emoji,
note,
}: IAccount) => {
const overflowRef = React.useRef<HTMLDivElement>(null);
const actionRef = React.useRef<HTMLDivElement>(null);
@ -169,7 +171,7 @@ const Account = ({
return (
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
<ProfilePopper
condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -212,7 +214,7 @@ const Account = ({
</LinkEl>
</ProfilePopper>
<Stack space={withAccountNote ? 1 : 0}>
<Stack space={withAccountNote || note ? 1 : 0}>
<HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
@ -251,7 +253,14 @@ const Account = ({
) : null}
</HStack>
{withAccountNote && (
{note ? (
<Text
size='sm'
className='mr-2'
>
{note}
</Text>
) : withAccountNote && (
<Text
size='sm'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,5 +1,5 @@
import classNames from 'clsx';
import { debounce } from 'lodash';
import debounce from 'lodash/debounce';
import React, { useRef } from 'react';
import { useDispatch } from 'react-redux';

Wyświetl plik

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

Wyświetl plik

@ -61,7 +61,7 @@
/* Emojis */
[data-markup] img.emojione {
@apply w-5 h-5;
@apply w-5 h-5 m-0;
}
/* Hide Markdown images (Pleroma) */

Wyświetl plik

@ -5,15 +5,18 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { cancelEventCompose } from 'soapbox/actions/events';
import { openModal, closeModal } from 'soapbox/actions/modals';
import { useAppDispatch, useAppSelector, usePrevious } from 'soapbox/hooks';
import { useAppDispatch, usePrevious } from 'soapbox/hooks';
import type { UnregisterCallback } from 'history';
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
import type { ReducerCompose } from 'soapbox/reducers/compose';
import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event';
const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
});
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
@ -25,6 +28,15 @@ export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>)
].some(check => check === true);
};
export const checkEventComposeContent = (compose?: ReturnType<typeof ReducerComposeEvent>) => {
return !!compose && [
compose.name.length > 0,
compose.status.length > 0,
compose.location !== null,
compose.banner !== null,
].some(check => check === true);
};
interface IModalRoot {
onCancel?: () => void,
onClose: (type?: ModalType) => void,
@ -46,8 +58,6 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const prevChildren = usePrevious(children);
const prevType = usePrevious(type);
const isEditing = useAppSelector(state => state.compose.get('compose-modal')?.id !== null);
const visible = !!children;
const handleKeyUp = (e: KeyboardEvent) => {
@ -58,13 +68,20 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const handleOnClose = () => {
dispatch((_, getState) => {
const hasComposeContent = checkComposeContent(getState().compose.get('compose-modal'));
const compose = getState().compose.get('compose-modal');
const hasComposeContent = checkComposeContent(compose);
const hasEventComposeContent = checkEventComposeContent(getState().compose_event);
if (hasComposeContent && type === 'COMPOSE') {
const isEditing = compose!.id !== null;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/trash.svg'),
heading: isEditing ? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' /> : <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
message: isEditing ? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' /> : <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
heading: isEditing
? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' />
: <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
message: isEditing
? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' />
: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
confirm: intl.formatMessage(messages.confirm),
onConfirm: () => {
dispatch(closeModal('COMPOSE'));
@ -74,7 +91,26 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
dispatch(closeModal('CONFIRM'));
},
}));
} else if (hasComposeContent && type === 'CONFIRM') {
} else if (hasEventComposeContent && type === 'COMPOSE_EVENT') {
const isEditing = getState().compose_event.id !== null;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/trash.svg'),
heading: isEditing
? <FormattedMessage id='confirmations.cancel_event_editing.heading' defaultMessage='Cancel event editing' />
: <FormattedMessage id='confirmations.delete_event.heading' defaultMessage='Delete event' />,
message: isEditing
? <FormattedMessage id='confirmations.cancel_event_editing.message' defaultMessage='Are you sure you want to cancel editing this event? All changes will be lost.' />
: <FormattedMessage id='confirmations.delete_event.message' defaultMessage='Are you sure you want to delete this event?' />,
confirm: intl.formatMessage(isEditing ? messages.cancelEditing : messages.confirm),
onConfirm: () => {
dispatch(closeModal('COMPOSE_EVENT'));
dispatch(cancelEventCompose());
},
onCancel: () => {
dispatch(closeModal('CONFIRM'));
},
}));
} else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') {
dispatch(closeModal('CONFIRM'));
} else {
onClose();

Wyświetl plik

@ -9,6 +9,7 @@ import AccountContainer from 'soapbox/containers/account-container';
import { useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility } from 'soapbox/utils/status';
import EventPreview from './event-preview';
import OutlineBox from './outline-box';
import StatusContent from './status-content';
import StatusReplyMentions from './status-reply-mentions';
@ -112,35 +113,37 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
<StatusReplyMentions status={status} hoverable={false} />
<Stack
className='relative z-0'
style={{ minHeight: status.hidden ? Math.max(minHeight, 208) + 12 : undefined }}
>
{(status.hidden) && (
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
ref={overlay}
/>
)}
<Stack space={4}>
<StatusContent
status={status}
collapsable
/>
{(status.card || status.media_attachments.size > 0) && (
<StatusMedia
{status.event ? <EventPreview status={status} hideAction /> : (
<Stack
className='relative z-0'
style={{ minHeight: status.hidden ? Math.max(minHeight, 208) + 12 : undefined }}
>
{(status.hidden) && (
<SensitiveContentOverlay
status={status}
muted={compose}
showMedia={showMedia}
visible={showMedia}
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>
</OutlineBox>
);

Wyświetl plik

@ -34,6 +34,7 @@ const messages = defineMessages({
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
events: { id: 'column.events', defaultMessage: 'Events' },
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
@ -208,6 +209,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
{features.events && (
<SidebarLink
to='/events'
icon={require('@tabler/icons/calendar-event.svg')}
text={intl.formatMessage(messages.events)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'

Wyświetl plik

@ -13,6 +13,7 @@ const messages = defineMessages({
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
events: { id: 'column.events', defaultMessage: 'Events' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
});
@ -57,6 +58,14 @@ const SidebarNavigation = () => {
});
}
if (features.events) {
menu.push({
to: '/events',
text: intl.formatMessage(messages.events),
icon: require('@tabler/icons/calendar-event.svg'),
});
}
if (settings.get('isDeveloper')) {
menu.push({
to: '/developers',

Wyświetl plik

@ -7,6 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
import { showAlertForError } from 'soapbox/actions/alerts';
import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { editEvent } from 'soapbox/actions/events';
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
@ -203,7 +204,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleEditClick: React.EventHandler<React.MouseEvent> = () => {
dispatch(editStatus(status.id));
if (status.event) dispatch(editEvent(status.id));
else dispatch(editStatus(status.id));
};
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {

Wyświetl plik

@ -43,7 +43,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
const size = status.media_attachments.size;
const firstAttachment = status.media_attachments.first();
let media = null;
let media: JSX.Element | null = null;
const setRef = (c: HTMLDivElement): void => {
if (c) {
@ -122,7 +122,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
const attachment = firstAttachment;
media = (
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer} >
<Bundle fetchComponent={Audio} loading={renderLoadingAudioPlayer}>
{(Component: any) => (
<Component
src={attachment.url}

Wyświetl plik

@ -15,6 +15,7 @@ import QuotedStatus from 'soapbox/features/status/containers/quoted-status-conta
import { useAppDispatch, useSettings } from 'soapbox/hooks';
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status';
import EventPreview from './event-preview';
import StatusActionBar from './status-action-bar';
import StatusContent from './status-content';
import StatusMedia from './status-media';
@ -28,7 +29,7 @@ import type {
Status as StatusEntity,
} from 'soapbox/types/entities';
// Defined in components/scrollable_list
// Defined in components/scrollable-list
export type ScrollPosition = { height: number, top: number };
const messages = defineMessages({
@ -383,30 +384,32 @@ const Status: React.FC<IStatus> = (props) => {
/>
)}
<Stack space={4}>
<StatusContent
status={actualStatus}
onClick={handleClick}
collapsable
translatable
/>
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
<Stack space={4}>
<StatusContent
status={actualStatus}
onClick={handleClick}
collapsable
translatable
/>
<TranslateButton status={actualStatus} />
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{quote}
</Stack>
)}
</Stack>
{quote}
</Stack>
)}
</Stack>
)}
</Stack>
{(!hideActionBar && !isUnderReview) && (

Wyświetl plik

@ -88,9 +88,14 @@ const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
);
interface ICardBody {
/** Classnames for the <div> element. */
className?: string
}
/** A card's body. */
const CardBody: React.FC = ({ children }): JSX.Element => (
<div data-testid='card-body'>{children}</div>
const CardBody: React.FC<ICardBody> = ({ className, children }): JSX.Element => (
<div data-testid='card-body' className={className}>{children}</div>
);
export { Card, CardHeader, CardTitle, CardBody };

Wyświetl plik

@ -40,7 +40,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
/** Right sidebar container in the UI. */
const Aside: React.FC = ({ children }) => (
<aside className='hidden xl:block xl:col-span-3'>
<StickyBox offsetTop={80} className='space-y-6 pb-12' >
<StickyBox offsetTop={80} className='space-y-6 pb-12'>
{children}
</StickyBox>
</aside>

Wyświetl plik

@ -3,8 +3,8 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../button/button';
import HStack from '../hstack/hstack';
import IconButton from '../icon-button/icon-button';
import Stack from '../stack/stack';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -115,7 +115,7 @@ const Modal: React.FC<IModal> = ({
</div>
{confirmationAction && (
<div className='mt-5 flex flex-row justify-between' data-testid='modal-actions'>
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
<div className='flex-grow'>
{cancelAction && (
<Button
@ -127,7 +127,7 @@ const Modal: React.FC<IModal> = ({
)}
</div>
<Stack space={2}>
<HStack space={2}>
{secondaryAction && (
<Button
theme='secondary'
@ -146,8 +146,8 @@ const Modal: React.FC<IModal> = ({
>
{confirmationText}
</Button>
</Stack>
</div>
</HStack>
</HStack>
)}
</div>
);

Wyświetl plik

@ -10,6 +10,7 @@ const spaces = {
3: 'space-y-3',
4: 'space-y-4',
5: 'space-y-5',
6: 'space-y-6',
10: 'space-y-10',
};
@ -23,6 +24,7 @@ const alignItemsOptions = {
bottom: 'items-end',
center: 'items-center',
start: 'items-start',
end: 'items-end',
};
interface IStack extends React.HTMLAttributes<HTMLDivElement> {

Wyświetl plik

@ -1,7 +1,7 @@
import classNames from 'clsx';
import React from 'react';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly' | 'onKeyDown' | 'onPaste'> {
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean,
/** The initial text in the input. */

Wyświetl plik

@ -6,25 +6,12 @@ import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/
import { Text } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
import sourceCode from 'soapbox/utils/code';
import { download } from 'soapbox/utils/download';
import { parseVersion } from 'soapbox/utils/features';
import { isNumber } from 'soapbox/utils/numbers';
import RegistrationModePicker from '../components/registration-mode-picker';
import type { AxiosResponse } from 'axios';
/** Download the file from the response instead of opening it in a tab. */
// https://stackoverflow.com/a/53230807
const download = (response: AxiosResponse, filename: string) => {
const url = URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
};
const Dashboard: React.FC = () => {
const dispatch = useAppDispatch();
const instance = useInstance();

Wyświetl plik

@ -1,6 +1,6 @@
import classNames from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, MessageDescriptor, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
@ -47,7 +47,8 @@ const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u20
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' },
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
@ -61,9 +62,10 @@ interface IComposeForm<ID extends string> {
shouldCondense?: boolean,
autoFocus?: boolean,
clickableAreaRef?: React.RefObject<HTMLDivElement>,
event?: string,
}
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef }: IComposeForm<ID>) => {
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event }: IComposeForm<ID>) => {
const history = useHistory();
const intl = useIntl();
const dispatch = useAppDispatch();
@ -240,6 +242,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
let publishText: string | JSX.Element = '';
let textareaPlaceholder: MessageDescriptor;
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
@ -265,9 +268,17 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
publishText = intl.formatMessage(messages.schedule);
}
if (event) {
textareaPlaceholder = messages.eventPlaceholder;
} else if (hasPoll) {
textareaPlaceholder = messages.pollPlaceholder;
} else {
textareaPlaceholder = messages.placeholder;
}
return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
{scheduledStatusCount > 0 && (
{scheduledStatusCount > 0 && !event && (
<Warning
message={(
<FormattedMessage
@ -288,13 +299,13 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
<WarningContainer composeId={id} />
{!shouldCondense && <ReplyIndicatorContainer composeId={id} />}
{!shouldCondense && !event && <ReplyIndicatorContainer composeId={id} />}
{!shouldCondense && <ReplyMentions composeId={id} />}
{!shouldCondense && !event && <ReplyMentions composeId={id} />}
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(hasPoll ? messages.pollPlaceholder : messages.placeholder)}
placeholder={intl.formatMessage(textareaPlaceholder)}
disabled={disabled}
value={text}
onChange={handleChange}

Wyświetl plik

@ -11,7 +11,7 @@ import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { DatePicker } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const isCurrentOrFutureDate = (date: Date) => {
export const isCurrentOrFutureDate = (date: Date) => {
return date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0);
};

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -51,6 +51,9 @@ const icons: Record<NotificationType, string> = {
'pleroma:emoji_reaction': require('@tabler/icons/mood-happy.svg'),
user_approved: require('@tabler/icons/user-plus.svg'),
update: require('@tabler/icons/pencil.svg'),
'pleroma:event_reminder': require('@tabler/icons/calendar-time.svg'),
'pleroma:participation_request': require('@tabler/icons/calendar-event.svg'),
'pleroma:participation_accepted': require('@tabler/icons/calendar-event.svg'),
};
const nameMessage = defineMessage({
@ -107,6 +110,18 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
id: 'notification.update',
defaultMessage: '{name} edited a post you interacted with',
},
'pleroma:event_reminder': {
id: 'notification.pleroma:event_reminder',
defaultMessage: 'An event you are participating in starts soon',
},
'pleroma:participation_request': {
id: 'notification.pleroma:participation_request',
defaultMessage: '{name} wants to join your event',
},
'pleroma:participation_accepted': {
id: 'notification.pleroma:participation_accepted',
defaultMessage: 'You were accepted to join the event',
},
});
const buildMessage = (
@ -297,6 +312,9 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'poll':
case 'update':
case 'pleroma:emoji_reaction':
case 'pleroma:event_reminder':
case 'pleroma:participation_accepted':
case 'pleroma:participation_request':
return status && typeof status === 'object' ? (
<Status
status={status}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -35,7 +35,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
if (video) {
media = (
<Bundle fetchComponent={Video} >
<Bundle fetchComponent={Video}>
{(Component: any) => (
<Component
preview={video.preview_url}
@ -58,7 +58,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
if (audio) {
media = (
<Bundle fetchComponent={Audio} >
<Bundle fetchComponent={Audio}>
{(Component: any) => (
<Component
src={audio.url}
@ -73,7 +73,7 @@ const StatusCheckBox: React.FC<IStatusCheckBox> = ({ id, disabled }) => {
}
} else {
media = (
<Bundle fetchComponent={MediaGallery} >
<Bundle fetchComponent={MediaGallery}>
{(Component: any) => <Component media={status.media_attachments} sensitive={status.sensitive} height={110} onOpenMedia={noop} />}
</Bundle>
);

Wyświetl plik

@ -1,10 +1,10 @@
import classNames from 'clsx';
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { debounce } from 'lodash';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { Redirect, useHistory } from 'react-router-dom';
import { createSelector } from 'reselect';
import {
@ -82,7 +82,7 @@ const getAncestorsIds = createSelector([
return ancestorsIds;
});
const getDescendantsIds = createSelector([
export const getDescendantsIds = createSelector([
(_: RootState, statusId: string) => statusId,
(state: RootState) => state.contexts.replies,
], (statusId, contextReplies) => {
@ -425,6 +425,12 @@ const Thread: React.FC<IThread> = (props) => {
const hasAncestors = ancestorsIds.size > 0;
const hasDescendants = descendantsIds.size > 0;
if (status?.event) {
return (
<Redirect to={`/@${status.getIn(['account', 'acct'])}/events/${status.id}`} />
);
}
if (!status && isLoaded) {
return (
<MissingIndicator />

Wyświetl plik

@ -1,10 +1,10 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import IconButton from 'soapbox/components/icon-button';
import { Modal } from 'soapbox/components/ui';
const messages = defineMessages({
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this modal.' },
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
});
@ -22,23 +22,13 @@ const BundleModalError: React.FC<IBundleModalError> = ({ onRetry, onClose }) =>
};
return (
<div className='modal-root__modal error-modal'>
<div className='error-modal__body'>
<IconButton title={intl.formatMessage(messages.retry)} icon='refresh' onClick={handleRetry} size={64} />
{intl.formatMessage(messages.error)}
</div>
<div className='error-modal__footer'>
<div>
<button
onClick={onClose}
className='error-modal__nav'
>
{intl.formatMessage(messages.close)}
</button>
</div>
</div>
</div>
<Modal
title={intl.formatMessage(messages.error)}
confirmationAction={onClose}
confirmationText={intl.formatMessage(messages.close)}
secondaryAction={handleRetry}
secondaryText={intl.formatMessage(messages.retry)}
/>
);
};

Wyświetl plik

@ -43,6 +43,9 @@ const LinkFooter: React.FC = (): JSX.Element => {
{features.profileDirectory && (
<FooterLink to='/directory'><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></FooterLink>
)}
{features.events && (
<FooterLink to='/events'><FormattedMessage id='navigation_bar.events' defaultMessage='Events' /></FooterLink>
)}
<FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
{features.filters && (

Wyświetl plik

@ -30,7 +30,10 @@ import {
CompareHistoryModal,
VerifySmsModal,
FamiliarFollowersModal,
ComposeEventModal,
JoinEventModal,
AccountModerationModal,
EventParticipantsModal,
} from 'soapbox/features/ui/util/async-components';
import BundleContainer from '../containers/bundle-container';
@ -68,7 +71,10 @@ const MODAL_COMPONENTS = {
'COMPARE_HISTORY': CompareHistoryModal,
'VERIFY_SMS': VerifySmsModal,
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
'COMPOSE_EVENT': ComposeEventModal,
'JOIN_EVENT': JoinEventModal,
'ACCOUNT_MODERATION': AccountModerationModal,
'EVENT_PARTICIPANTS': EventParticipantsModal,
};
export type ModalType = keyof typeof MODAL_COMPONENTS | null;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -20,8 +20,6 @@ const messages = defineMessages({
blankslate: { id: 'report.reason.blankslate', defaultMessage: 'You have removed all statuses from being selected.' },
done: { id: 'report.done', defaultMessage: 'Done' },
next: { id: 'report.next', defaultMessage: 'Next' },
close: { id: 'lightbox.close', defaultMessage: 'Close' },
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
submit: { id: 'report.submit', defaultMessage: 'Submit' },
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
previous: { id: 'report.previous', defaultMessage: 'Previous' },

Wyświetl plik

@ -15,7 +15,7 @@ const messages = defineMessages({
interface IUnauthorizedModal {
/** Unauthorized action type. */
action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE',
action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE' | 'JOIN',
/** Close event handler. */
onClose: (modalType: string) => void,
/** ActivityPub ID of the account OR status being acted upon. */
@ -89,6 +89,9 @@ const UnauthorizedModal: React.FC<IUnauthorizedModal> = ({ action, onClose, acco
} else if (action === 'POLL_VOTE') {
header = <FormattedMessage id='remote_interaction.poll_vote_title' defaultMessage='Vote in a poll remotely' />;
button = <FormattedMessage id='remote_interaction.poll_vote' defaultMessage='Proceed to vote' />;
} else if (action === 'JOIN') {
header = <FormattedMessage id='remote_interaction.event_join_title' defaultMessage='Join an event remotely' />;
button = <FormattedMessage id='remote_interaction.event_join' defaultMessage='Proceed to join' />;
}
return (

Wyświetl plik

@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { cancelEventCompose } from 'soapbox/actions/events';
import { closeModal } from 'soapbox/actions/modals';
import { cancelReport } from 'soapbox/actions/reports';
@ -26,6 +27,9 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
case 'COMPOSE':
dispatch(cancelReplyCompose());
break;
case 'COMPOSE_EVENT':
dispatch(cancelEventCompose());
break;
case 'REPORT':
dispatch(cancelReport());
break;

Wyświetl plik

@ -12,6 +12,7 @@ import { fetchAnnouncements } from 'soapbox/actions/announcements';
import { fetchChats } from 'soapbox/actions/chats';
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
import { uploadEventBanner } from 'soapbox/actions/events';
import { fetchFilters } from 'soapbox/actions/filters';
import { fetchMarker } from 'soapbox/actions/markers';
import { openModal } from 'soapbox/actions/modals';
@ -28,6 +29,7 @@ import { Layout } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks';
import AdminPage from 'soapbox/pages/admin-page';
import DefaultPage from 'soapbox/pages/default-page';
import EventPage from 'soapbox/pages/event-page';
import HomePage from 'soapbox/pages/home-page';
import ProfilePage from 'soapbox/pages/profile-page';
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
@ -111,6 +113,9 @@ import {
AuthTokenList,
Quotes,
ServiceWorkerInfo,
EventInformation,
EventDiscussion,
Events,
} from './util/async-components';
import { WrappedRoute } from './util/react-router-helpers';
@ -248,6 +253,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/search' page={DefaultPage} component={Search} content={children} />
{features.suggestions && <WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
{features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />}
{features.events && <WrappedRoute path='/events' page={DefaultPage} component={Events} content={children} />}
{features.chats && <WrappedRoute path='/chats' exact page={DefaultPage} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />}
@ -267,6 +273,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/@:username/pins' component={PinnedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/posts/:statusId' publicRoute exact page={StatusPage} component={Status} content={children} />
<WrappedRoute path='/@:username/posts/:statusId/quotes' publicRoute page={StatusPage} component={Quotes} content={children} />
<WrappedRoute path='/@:username/events/:statusId' publicRoute exact page={EventPage} component={EventInformation} content={children} />
<WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
@ -379,7 +387,9 @@ const UI: React.FC = ({ children }) => {
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
const modals = getState().modals;
const isModalOpen = modals.last()?.modalType === 'COMPOSE';
dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl));
const isEventsModalOpen = modals.last()?.modalType === 'COMPOSE_EVENT';
if (isEventsModalOpen) dispatch(uploadEventBanner(e.dataTransfer.files[0], intl));
else dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl));
}
});
};

Wyświetl plik

@ -509,3 +509,31 @@ export function AnnouncementsPanel() {
export function Quotes() {
return import(/*webpackChunkName: "features/quotes" */'../../quotes');
}
export function ComposeEventModal() {
return import(/* webpackChunkName: "features/compose_event_modal" */'../components/modals/compose-event-modal/compose-event-modal');
}
export function JoinEventModal() {
return import(/* webpackChunkName: "features/join_event_modal" */'../components/modals/join-event-modal');
}
export function EventHeader() {
return import(/* webpackChunkName: "features/event" */'../../event/components/event-header');
}
export function EventInformation() {
return import(/* webpackChunkName: "features/event" */'../../event/event-information');
}
export function EventDiscussion() {
return import(/* webpackChunkName: "features/event" */'../../event/event-discussion');
}
export function EventParticipantsModal() {
return import(/* webpackChunkName: "modals/event-participants-modal" */'../components/modals/event-participants-modal');
}
export function Events() {
return import(/* webpackChunkName: "features/events" */'../../events');
}

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Опитай отново",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Klask endro",
"bundle_column_error.title": "Fazi rouedad",
"bundle_modal_error.close": "Serriñ",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Klask endro",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -183,7 +183,7 @@
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.message": "Something went wrong while loading this modal.",
"bundle_modal_error.retry": "Try again",
"card.back.label": "Back",
"chat_box.actions.send": "Send",

Wyświetl plik

@ -24,7 +24,7 @@ export const AdminReportRecord = ImmutableRecord({
rules: ImmutableList<string>(),
statuses: ImmutableList<EmbeddedEntity<Status>>(),
target_account: null as EmbeddedEntity<Account | ReducerAccount>,
updated_at: new Date(),
updated_at: new Date(),
});
const normalizePleromaReport = (report: ImmutableMap<string, any>) => {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -31,6 +31,21 @@ const MAX_DEPTH = 1;
const domParser = new DOMParser();
export type EventJoinMode = 'free' | 'restricted' | 'invite';
export type EventJoinState = 'pending' | 'reject' | 'accept';
export const EventRecord = ImmutableRecord({
name: '',
start_time: null as string | null,
end_time: null as string | null,
join_mode: null as EventJoinMode | null,
participants_count: 0,
location: null as ImmutableMap<string, any> | null,
join_state: null as EventJoinState | null,
banner: null as Attachment | null,
links: ImmutableList<Attachment>(),
});
// https://docs.joinmastodon.org/entities/status/
export const StatusRecord = ImmutableRecord({
account: null as EmbeddedEntity<Account | ReducerAccount>,
@ -66,6 +81,7 @@ export const StatusRecord = ImmutableRecord({
uri: '',
url: '',
visibility: 'public' as StatusVisibility,
event: null as ReturnType<typeof EventRecord> | null,
// Internal fields
contentHtml: '',
@ -209,6 +225,33 @@ const addInternalFields = (status: ImmutableMap<string, any>) => {
});
};
// Normalize event
const normalizeEvent = (status: ImmutableMap<string, any>) => {
if (status.getIn(['pleroma', 'event'])) {
const firstAttachment = status.get('media_attachments').first();
let banner = null;
let mediaAttachments = status.get('media_attachments');
if (firstAttachment && firstAttachment.description === 'Banner' && firstAttachment.type === 'image') {
banner = normalizeAttachment(firstAttachment);
mediaAttachments = mediaAttachments.shift();
}
const links = mediaAttachments.filter((attachment: Attachment) => attachment.pleroma.get('mime_type') === 'text/html');
mediaAttachments = mediaAttachments.filter((attachment: Attachment) => attachment.pleroma.get('mime_type') !== 'text/html');
const event = EventRecord(
(status.getIn(['pleroma', 'event']) as ImmutableMap<string, any>)
.set('banner', banner)
.set('links', links),
);
status
.set('event', event)
.set('media_attachments', mediaAttachments);
}
};
export const normalizeStatus = (status: Record<string, any>, depth = 0) => {
return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => {
@ -225,6 +268,7 @@ export const normalizeStatus = (status: Record<string, any>, depth = 0) => {
fixSensitivity(status);
normalizeReblogQuote(status, depth);
addInternalFields(status);
normalizeEvent(status);
}),
);
};

Wyświetl plik

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

Wyświetl plik

@ -91,6 +91,7 @@ describe('compose reducer', () => {
it('uses \'public\' scope as default', () => {
const action = {
type: actions.COMPOSE_REPLY,
id: 'compose-modal',
status: ImmutableRecord({})(),
account: ImmutableRecord({})(),
};
@ -101,6 +102,7 @@ describe('compose reducer', () => {
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = {
type: actions.COMPOSE_REPLY,
id: 'compose-modal',
status: ImmutableRecord({ visibility: 'direct' })(),
account: ImmutableRecord({})(),
};
@ -111,6 +113,7 @@ describe('compose reducer', () => {
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = {
type: actions.COMPOSE_REPLY,
id: 'compose-modal',
status: ImmutableRecord({ visibility: 'private' })(),
account: ImmutableRecord({})(),
};
@ -121,6 +124,7 @@ describe('compose reducer', () => {
const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = {
type: actions.COMPOSE_REPLY,
id: 'compose-modal',
status: ImmutableRecord({ visibility: 'unlisted' })(),
account: ImmutableRecord({})(),
};
@ -131,6 +135,7 @@ describe('compose reducer', () => {
const state = initialState.set('default', ReducerCompose({ privacy: 'private' }));
const action = {
type: actions.COMPOSE_REPLY,
id: 'compose-modal',
status: ImmutableRecord({ visibility: 'public' })(),
account: ImmutableRecord({})(),
};
@ -141,6 +146,7 @@ describe('compose reducer', () => {
const state = initialState.set('default', ReducerCompose({ privacy: 'unlisted' }));
const action = {
type: actions.COMPOSE_REPLY,
id: 'compose-modal',
status: ImmutableRecord({ visibility: 'public' })(),
account: ImmutableRecord({})(),
};

Wyświetl plik

@ -27,6 +27,18 @@ describe('status_lists reducer', () => {
isLoading: null,
items: [],
},
joined_events: {
next: null,
loaded: false,
isLoading: null,
items: [],
},
recent_events: {
next: null,
loaded: false,
isLoading: null,
items: [],
},
});
});
});

Wyświetl plik

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

Wyświetl plik

@ -48,6 +48,7 @@ import {
COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS,
COMPOSE_EVENT_REPLY,
} from '../actions/compose';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from '../actions/me';
import { SETTING_CHANGE, FE_NAME } from '../actions/settings';
@ -305,7 +306,7 @@ export default function compose(state = initialState, action: AnyAction) {
case COMPOSE_COMPOSING_CHANGE:
return updateCompose(state, action.id, compose => compose.set('is_composing', action.value));
case COMPOSE_REPLY:
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
return updateCompose(state, action.id, compose => compose.withMutations(map => {
const defaultCompose = state.get('default')!;
map.set('in_reply_to', action.status.get('id'));
@ -317,6 +318,12 @@ export default function compose(state = initialState, action: AnyAction) {
map.set('idempotencyKey', uuid());
map.set('content_type', defaultCompose.content_type);
}));
case COMPOSE_EVENT_REPLY:
return updateCompose(state, action.id, compose => compose.withMutations(map => {
map.set('in_reply_to', action.status.get('id'));
map.set('to', statusToMentionsArray(action.status, action.account));
map.set('idempotencyKey', uuid());
}));
case COMPOSE_QUOTE:
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
const author = action.status.getIn(['account', 'acct']);
@ -341,7 +348,10 @@ export default function compose(state = initialState, action: AnyAction) {
case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS:
return updateCompose(state, action.id, () => state.get('default')!.set('idempotencyKey', uuid()));
return updateCompose(state, action.id, () => state.get('default')!.withMutations(map => {
map.set('idempotencyKey', uuid());
map.set('in_reply_to', action.id.startsWith('reply:') ? action.id.slice(6) : null);
}));
case COMPOSE_SUBMIT_FAIL:
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
case COMPOSE_UPLOAD_CHANGE_FAIL:

Wyświetl plik

@ -20,6 +20,7 @@ import chat_message_lists from './chat-message-lists';
import chat_messages from './chat-messages';
import chats from './chats';
import compose from './compose';
import compose_event from './compose-event';
import contexts from './contexts';
import conversations from './conversations';
import custom_emojis from './custom-emojis';
@ -31,6 +32,7 @@ import instance from './instance';
import listAdder from './list-adder';
import listEditor from './list-editor';
import lists from './lists';
import locations from './locations';
import me from './me';
import meta from './meta';
import modals from './modals';
@ -87,6 +89,7 @@ const reducers = {
lists,
listEditor,
listAdder,
locations,
filters,
conversations,
suggestions,
@ -117,6 +120,7 @@ const reducers = {
rules,
history,
announcements,
compose_event,
entities,
};

Wyświetl plik

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

Wyświetl plik

@ -21,6 +21,14 @@ import {
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
BOOKMARKED_STATUSES_EXPAND_FAIL,
} from '../actions/bookmarks';
import {
RECENT_EVENTS_FETCH_REQUEST,
RECENT_EVENTS_FETCH_SUCCESS,
RECENT_EVENTS_FETCH_FAIL,
JOINED_EVENTS_FETCH_REQUEST,
JOINED_EVENTS_FETCH_SUCCESS,
JOINED_EVENTS_FETCH_FAIL,
} from '../actions/events';
import {
FAVOURITED_STATUSES_FETCH_REQUEST,
FAVOURITED_STATUSES_FETCH_SUCCESS,
@ -77,6 +85,8 @@ const initialState: State = ImmutableMap({
bookmarks: StatusListRecord(),
pins: StatusListRecord(),
scheduled_statuses: StatusListRecord(),
recent_events: StatusListRecord(),
joined_events: StatusListRecord(),
});
const getStatusId = (status: string | StatusEntity) => typeof status === 'string' ? status : status.id;
@ -187,6 +197,18 @@ export default function statusLists(state = initialState, action: AnyAction) {
return normalizeList(state, `quotes:${action.statusId}`, action.statuses, action.next);
case STATUS_QUOTES_EXPAND_SUCCESS:
return appendToList(state, `quotes:${action.statusId}`, action.statuses, action.next);
case RECENT_EVENTS_FETCH_REQUEST:
return setLoading(state, 'recent_events', true);
case RECENT_EVENTS_FETCH_FAIL:
return setLoading(state, 'recent_events', false);
case RECENT_EVENTS_FETCH_SUCCESS:
return normalizeList(state, 'recent_events', action.statuses, action.next);
case JOINED_EVENTS_FETCH_REQUEST:
return setLoading(state, 'joined_events', true);
case JOINED_EVENTS_FETCH_FAIL:
return setLoading(state, 'joined_events', false);
case JOINED_EVENTS_FETCH_SUCCESS:
return normalizeList(state, 'joined_events', action.statuses, action.next);
default:
return state;
}

Wyświetl plik

@ -11,6 +11,12 @@ import {
EMOJI_REACT_REQUEST,
UNEMOJI_REACT_REQUEST,
} from '../actions/emoji-reacts';
import {
EVENT_JOIN_REQUEST,
EVENT_JOIN_FAIL,
EVENT_LEAVE_REQUEST,
EVENT_LEAVE_FAIL,
} from '../actions/events';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
import {
REBLOG_REQUEST,
@ -281,6 +287,13 @@ export default function statuses(state = initialState, action: AnyAction): State
return deleteTranslation(state, action.id);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case EVENT_JOIN_REQUEST:
return state.setIn([action.id, 'event', 'join_state'], 'pending');
case EVENT_JOIN_FAIL:
case EVENT_LEAVE_REQUEST:
return state.setIn([action.id, 'event', 'join_state'], null);
case EVENT_LEAVE_FAIL:
return state.setIn([action.id, 'event', 'join_state'], action.previousState);
default:
return state;
}

Wyświetl plik

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

Wyświetl plik

@ -16,11 +16,11 @@ import {
FOLLOW_REQUEST_REJECT_SUCCESS,
PINNED_ACCOUNTS_FETCH_SUCCESS,
BIRTHDAY_REMINDERS_FETCH_SUCCESS,
} from '../actions/accounts';
} from 'soapbox/actions/accounts';
import {
BLOCKS_FETCH_SUCCESS,
BLOCKS_EXPAND_SUCCESS,
} from '../actions/blocks';
} from 'soapbox/actions/blocks';
import {
DIRECTORY_FETCH_REQUEST,
DIRECTORY_FETCH_SUCCESS,
@ -28,22 +28,30 @@ import {
DIRECTORY_EXPAND_REQUEST,
DIRECTORY_EXPAND_SUCCESS,
DIRECTORY_EXPAND_FAIL,
} from '../actions/directory';
} from 'soapbox/actions/directory';
import {
EVENT_PARTICIPATIONS_EXPAND_SUCCESS,
EVENT_PARTICIPATIONS_FETCH_SUCCESS,
EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS,
EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS,
EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS,
EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS,
} from 'soapbox/actions/events';
import {
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
} from '../actions/familiar-followers';
} from 'soapbox/actions/familiar-followers';
import {
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS,
REACTIONS_FETCH_SUCCESS,
} from '../actions/interactions';
} from 'soapbox/actions/interactions';
import {
MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS,
} from '../actions/mutes';
} from 'soapbox/actions/mutes';
import {
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
} from 'soapbox/actions/notifications';
import type { APIEntity } from 'soapbox/types/entities';
@ -65,6 +73,17 @@ const ReactionListRecord = ImmutableRecord({
isLoading: false,
});
export const ParticipationRequestRecord = ImmutableRecord({
account: '',
participation_message: null as string | null,
});
const ParticipationRequestListRecord = ImmutableRecord({
next: null as string | null,
items: ImmutableOrderedSet<ParticipationRequest>(),
isLoading: false,
});
export const ReducerRecord = ImmutableRecord({
followers: ImmutableMap<string, List>(),
following: ImmutableMap<string, List>(),
@ -78,14 +97,18 @@ export const ReducerRecord = ImmutableRecord({
pinned: ImmutableMap<string, List>(),
birthday_reminders: ImmutableMap<string, List>(),
familiar_followers: ImmutableMap<string, List>(),
event_participations: ImmutableMap<string, List>(),
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
});
type State = ReturnType<typeof ReducerRecord>;
export type List = ReturnType<typeof ListRecord>;
type Reaction = ReturnType<typeof ReactionRecord>;
type ReactionList = ReturnType<typeof ReactionListRecord>;
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
type Items = ImmutableOrderedSet<string>;
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers', string];
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests', string];
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
@ -170,6 +193,33 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
return normalizeList(state, ['birthday_reminders', action.id], action.accounts, action.next);
case FAMILIAR_FOLLOWERS_FETCH_SUCCESS:
return normalizeList(state, ['familiar_followers', action.id], action.accounts, action.next);
case EVENT_PARTICIPATIONS_FETCH_SUCCESS:
return normalizeList(state, ['event_participations', action.id], action.accounts, action.next);
case EVENT_PARTICIPATIONS_EXPAND_SUCCESS:
return appendToList(state, ['event_participations', action.id], action.accounts, action.next);
case EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS:
return state.setIn(['event_participation_requests', action.id], ParticipationRequestListRecord({
next: action.next,
items: ImmutableOrderedSet(action.participations.map(({ account, participation_message }: APIEntity) => ParticipationRequestRecord({
account: account.id,
participation_message,
}))),
}));
case EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS:
return state.updateIn(
['event_participation_requests', action.id, 'items'],
(items) => (items as ImmutableOrderedSet<ParticipationRequest>)
.union(action.participations.map(({ account, participation_message }: APIEntity) => ParticipationRequestRecord({
account: account.id,
participation_message,
}))),
);
case EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS:
case EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS:
return state.updateIn(
['event_participation_requests', action.id, 'items'],
items => (items as ImmutableOrderedSet<ParticipationRequest>).filter(({ account }) => account !== action.accountId),
);
default:
return state;
}

Wyświetl plik

@ -140,7 +140,7 @@ const handlePush = (event: PushEvent) => {
event.waitUntil(
fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => {
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),
icon: notification.account.avatar_static,
timestamp: notification.created_at && Number(new Date(notification.created_at)),

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -280,6 +280,22 @@ const getInstanceFeatures = (instance: Instance) => {
*/
ethereumLogin: v.software === MITRA,
/**
* Ability to create and perform actions on events.
* @see POST /api/v1/pleroma/events
* @see GET /api/v1/pleroma/events/joined_events
* @see PUT /api/v1/pleroma/events/:id
* @see GET /api/v1/pleroma/events/:id/participations
* @see GET /api/v1/pleroma/events/:id/participation_requests
* @see POST /api/v1/pleroma/events/:id/participation_requests/:participant_id/authorize
* @see POST /api/v1/pleroma/events/:id/participation_requests/:participant_id/reject
* @see POST /api/v1/pleroma/events/:id/join
* @see POST /api/v1/pleroma/events/:id/leave
* @see GET /api/v1/pleroma/events/:id/ics
* @see GET /api/v1/pleroma/search/location
*/
events: v.software === PLEROMA && v.build === REBASED && gte(v.version, '2.4.50'),
/**
* Ability to address recipients of a status explicitly (with `to`).
* @see POST /api/v1/statuses

Wyświetl plik

@ -12,6 +12,9 @@ const NOTIFICATION_TYPES = [
'pleroma:emoji_reaction',
'user_approved',
'update',
'pleroma:event_reminder',
'pleroma:participation_request',
'pleroma:participation_accepted',
] as const;
/** Notification types to exclude from the "All" filter by default. */

Wyświetl plik

@ -100,7 +100,7 @@ noscript {
}
.emojione {
@apply w-5 h-5 -mt-[3px] inline;
@apply w-4 h-4 -mt-[0.2ex] mb-[0.2ex] inline-block align-middle object-contain;
}
// Virtuoso empty placeholder fix.

Wyświetl plik

@ -113,9 +113,3 @@ a.button {
margin-right: 0.6em;
}
}
.button--welcome {
.emojione {
margin: -1px 6px 0 -4px;
}
}

Wyświetl plik

@ -8,12 +8,6 @@
}
}
.muted {
.emojione {
opacity: 0.5;
}
}
a.account__display-name {
&:hover strong {
text-decoration: underline;

Wyświetl plik

@ -155,20 +155,6 @@
transition: height 0.4s ease, opacity 0.4s ease;
}
.emojione {
display: inline-block;
font-size: inherit;
vertical-align: middle;
object-fit: contain;
margin: -0.2ex 0.15em 0.2ex;
width: 16px;
height: 16px;
img {
width: auto;
}
}
.image-loader {
position: relative;
width: 100%;