Event pages?

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-09-08 23:25:02 +02:00
parent b97518d600
commit f7c09461fd
20 changed files with 1037 additions and 164 deletions

View File

@ -1,10 +1,10 @@
import { defineMessages, IntlShape } from 'react-intl';
import api from 'soapbox/api';
import api, { getLinks } from 'soapbox/api';
import { formatBytes } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize_image';
import { importFetchedStatus } from './importer';
import { importFetchedAccounts, importFetchedStatus } from './importer';
import { fetchMedia, uploadMedia } from './media';
import { closeModal } from './modals';
import snackbar from './snackbar';
@ -43,6 +43,24 @@ 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 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: 'create_event.submit_success', defaultMessage: 'Your event was created' },
@ -237,7 +255,9 @@ 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;
if (!status || !status.event || status.event.join_state) {
return dispatch(noOp);
}
dispatch(joinEventRequest());
@ -273,7 +293,9 @@ const leaveEvent = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const status = getState().statuses.get(id);
if (!status || !status.event || !status.event.join_state) return;
if (!status || !status.event || !status.event.join_state) {
return dispatch(noOp);
}
dispatch(leaveEventRequest());
@ -299,6 +321,146 @@ const leaveEventFail = (error: AxiosError) => ({
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 fetchEventIcs = (id: string) =>
(dispatch: any, getState: () => RootState) =>
api(getState).get(`/api/v1/pleroma/events/${id}/ics`);
export {
LOCATION_SEARCH_REQUEST,
LOCATION_SEARCH_SUCCESS,
@ -324,6 +486,18 @@ export {
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,
locationSearch,
changeCreateEventName,
changeCreateEventDescription,
@ -350,4 +524,21 @@ export {
leaveEventRequest,
leaveEventSuccess,
leaveEventFail,
fetchEventParticipations,
fetchEventParticipationsRequest,
fetchEventParticipationsSuccess,
fetchEventParticipationsFail,
expandEventParticipations,
expandEventParticipationsRequest,
expandEventParticipationsSuccess,
expandEventParticipationsFail,
fetchEventParticipationRequests,
fetchEventParticipationRequestsRequest,
fetchEventParticipationRequestsSuccess,
fetchEventParticipationRequestsFail,
expandEventParticipationRequests,
expandEventParticipationRequestsRequest,
expandEventParticipationRequestsSuccess,
expandEventParticipationRequestsFail,
fetchEventIcs,
};

View File

@ -1,10 +1,9 @@
import React, { useCallback } from 'react';
import { defineMessages, FormattedDate, FormattedMessage, useIntl } from 'react-intl';
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 { buildStatus } from 'soapbox/features/scheduled_statuses/builder';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
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';
@ -13,17 +12,17 @@ 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,
status: StatusEntity
}
const EventPreview: React.FC<IEventPreview> = ({ status }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const me = useAppSelector((state) => state.me);
@ -32,112 +31,6 @@ const EventPreview: React.FC<IEventPreview> = ({ status }) => {
const banner = status.media_attachments?.find(({ description }) => description === 'Banner');
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 renderDate = useCallback(() => {
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 sameDay = startDate.getDate() === endDate.getDate() && startDate.getMonth() === endDate.getMonth() && startDate.getFullYear() === endDate.getFullYear();
if (sameDay) {
date = (
<>
<FormattedDate value={event.start_time} year='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={1}>
<Icon src={require('@tabler/icons/calendar.svg')} />
<span>{date}</span>
</HStack>
);
}, [event.start_time, event.end_time]);
const renderAction = useCallback(() => {
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 = handleJoin;
}
return (
<Button
size='sm'
theme='secondary'
icon={buttonIcon}
onClick={buttonAction}
disabled={buttonDisabled}
>
{buttonLabel}
</Button>
);
}, [event.join_state]);
return (
<div className='rounded-lg bg-gray-100 dark:bg-primary-800 shadow-xl relative overflow-hidden'>
<div className='absolute top-28 right-3'>
@ -149,16 +42,16 @@ const EventPreview: React.FC<IEventPreview> = ({ status }) => {
>
<FormattedMessage id='event.manage' defaultMessage='Manage' />
</Button>
) : renderAction()}
) : <EventActionButton status={status} />}
</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={banner.url} />}
{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}>
<Text weight='semibold'>{event.name}</Text>
<div className='flex gap-y-1 gap-x-2 flex-wrap text-gray-700 dark:text-gray-600'>
<HStack alignItems='center' space={1}>
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/user.svg')} />
<span>
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
@ -166,10 +59,10 @@ const EventPreview: React.FC<IEventPreview> = ({ status }) => {
</span>
</HStack>
{renderDate()}
<EventDate status={status} />
{event.location && (
<HStack alignItems='center' space={1}>
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/map-pin.svg')} />
<span>
{event.location.get('name')}

View File

@ -22,6 +22,8 @@ interface IStatusMedia {
showMedia?: boolean,
/** Callback when visibility is toggled (eg clicked through NSFW). */
onToggleVisibility?: () => void,
/** Whether or not to hide image describer as 'Banner' */
excludeBanner?: boolean,
}
/** Render media attachments for a status. */
@ -31,14 +33,17 @@ const StatusMedia: React.FC<IStatusMedia> = ({
onClick,
showMedia = true,
onToggleVisibility = () => {},
excludeBanner = false,
}) => {
const dispatch = useAppDispatch();
const [mediaWrapperWidth, setMediaWrapperWidth] = useState<number | undefined>(undefined);
const size = status.media_attachments.size;
const firstAttachment = status.media_attachments.first();
const mediaAttachments = excludeBanner ? status.media_attachments.filter(({ description }) => description !== 'Banner') : status.media_attachments;
let media = null;
const size = mediaAttachments.size;
const firstAttachment = mediaAttachments.first();
let media: JSX.Element | null = null;
const setRef = (c: HTMLDivElement): void => {
if (c) {
@ -70,7 +75,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
if (muted) {
media = (
<AttachmentThumbs
media={status.media_attachments}
media={mediaAttachments}
onClick={onClick}
sensitive={status.sensitive}
/>
@ -142,7 +147,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
<Bundle fetchComponent={MediaGallery} loading={renderLoadingMediaGallery}>
{(Component: any) => (
<Component
media={status.media_attachments}
media={mediaAttachments}
sensitive={status.sensitive}
inReview={status.visibility === 'self'}
height={285}

View File

@ -21,13 +21,15 @@ const justifyContentOptions = {
const alignItemsOptions = {
center: 'items-center',
start: 'items-start',
end: 'items-end',
};
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** Size of the gap between elements. */
space?: SIZES,
/** Horizontal alignment of children. */
alignItems?: 'center',
alignItems?: 'center' | 'start' | 'end',
/** Vertical alignment of children. */
justifyContent?: 'center',
/** Extra class names on the <div> element. */
@ -43,7 +45,7 @@ const Stack: React.FC<IStack> = (props) => {
return (
<div
{...filteredProps}
className={classNames('flex flex-col', {
className={classNames('flex flex-col items', {
// @ts-ignore
[spaces[space]]: typeof space !== 'undefined',
// @ts-ignore

View File

@ -6,25 +6,12 @@ import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/
import { Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } 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 = useAppSelector(state => state.instance);

View File

@ -0,0 +1,88 @@
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 } from 'soapbox/hooks';
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,
}
const EventActionButton: React.FC<IEventAction> = ({ status }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
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));
}
};
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 = handleJoin;
}
return (
<Button
size='sm'
theme='secondary'
icon={buttonIcon}
onClick={buttonAction}
disabled={buttonDisabled}
>
{buttonLabel}
</Button>
);
};
export default EventActionButton;

View File

@ -0,0 +1,58 @@
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 sameDay = startDate.getDate() === endDate.getDate() && startDate.getMonth() === endDate.getMonth() && startDate.getFullYear() === endDate.getFullYear();
if (sameDay) {
date = (
<>
<FormattedDate value={event.start_time} year='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;

View File

@ -0,0 +1,163 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { fetchEventIcs } from 'soapbox/actions/events';
import { openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still_image';
import { 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, useAppSelector } from 'soapbox/hooks';
import { download } from 'soapbox/utils/download';
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' },
});
interface IEventHeader {
status?: StatusEntity,
}
const EventHeader: React.FC<IEventHeader> = ({ status }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const me = useAppSelector(state => state.me);
if (!status || !status.event) {
return (
<>
<div className='-mt-4 -mx-4'>
<div className='relative h-48 w-full lg:h-64 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 = status.media_attachments?.find(({ description }) => description === 'Banner');
const handleHeaderClick: React.MouseEventHandler<HTMLAnchorElement> = (e) => {
e.preventDefault();
const index = status.media_attachments!.findIndex(({ description }) => description === 'Banner');
dispatch(openModal('MEDIA', { media: status.media_attachments, index }));
};
const handleExportClick: React.MouseEventHandler = e => {
dispatch(fetchEventIcs(status.id)).then((response) => {
download(response, 'calendar.ics');
}).catch(() => {});
e.preventDefault();
};
const menu: MenuType = [
{
text: 'Export to your calendar',
action: handleExportClick,
icon: require('@tabler/icons/calendar-plus.svg'),
},
];
return (
<>
<div className='-mt-4 -mx-4'>
<div className='relative h-48 w-full lg:h-64 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'
/>
</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>
{menu.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 !== me && <EventActionButton status={status} />}
</HStack>
<Stack space={1}>
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/user.svg')} />
<span>
<FormattedMessage
id='event.organized_by'
defaultMessage='Organized by {name}'
values={{
name: (
<Link className='mention' to={`/@${account.acct}`}>
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
{account.verified && <VerificationBadge />}
</Link>
),
}}
/>
</span>
</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;

View File

@ -0,0 +1,227 @@
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createSelector } from 'reselect';
import { fetchStatusWithContext, fetchNext } from 'soapbox/actions/statuses';
import MissingIndicator from 'soapbox/components/missing_indicator';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
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 ThreadStatus from '../status/components/thread-status';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { RootState } from 'soapbox/store';
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
const getStatus = makeGetStatus();
const getDescendantsIds = createSelector([
(_: RootState, statusId: string) => statusId,
(state: RootState) => state.contexts.replies,
], (statusId, contextReplies) => {
let descendantsIds = ImmutableOrderedSet<string>();
const ids = [statusId];
while (ids.length > 0) {
const id = ids.shift();
if (!id) break;
const replies = contextReplies.get(id);
if (descendantsIds.includes(id)) {
break;
}
if (statusId !== id) {
descendantsIds = descendantsIds.union([id]);
}
if (replies) {
replies.reverse().forEach((reply: string) => {
ids.unshift(reply);
});
}
}
return descendantsIds;
});
type RouteParams = { statusId: string };
interface IThread {
params: RouteParams,
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void,
onOpenVideo: (video: AttachmentEntity, time: number) => void,
}
const Thread: React.FC<IThread> = (props) => {
const dispatch = useAppDispatch();
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
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);
/** Fetch the status (and context) from the API. */
const fetchData = async() => {
const { params } = props;
const { statusId } = params;
const { next } = await dispatch(fetchStatusWithContext(statusId));
setNext(next);
};
// Load data.
useEffect(() => {
fetchData().then(() => {
setIsLoaded(true);
}).catch(() => {
setIsLoaded(true);
});
}, [props.params.statusId]);
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 handleRefresh = () => {
return fetchData();
};
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 (
<PullToRefresh onRefresh={handleRefresh}>
<Stack space={2}>
<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}
>
{children}
</ScrollableList>
</div>
</Stack>
</PullToRefresh>
);
};
export default Thread;

View File

@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react';
import { fetchStatus } from 'soapbox/actions/statuses';
import MissingIndicator from 'soapbox/components/missing_indicator';
import StatusMedia from 'soapbox/components/status-media';
import { Stack, Text } from 'soapbox/components/ui';
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';
const getStatus = makeGetStatus();
type RouteParams = { statusId: string };
interface IEventInformation {
params: RouteParams,
}
const EventInformation: React.FC<IEventInformation> = ({ params }) => {
const dispatch = useAppDispatch();
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 = (): void => {
setShowMedia(!showMedia);
};
if (!status && isLoaded) {
return (
<MissingIndicator />
);
} else if (!status) return null;
return (
<Stack className='mt-4 sm:p-2' space={2}>
<Text
className='break-words status__content'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
/>
<StatusMedia
status={status}
excludeBanner
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
</Stack>
);
};
export default EventInformation;

View File

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

View File

@ -32,7 +32,7 @@ const AccountNoteModal: React.FC<IAccountNoteModal> = ({ statusId }) => {
const handleSubmit = () => {
setIsSubmitting(true);
dispatch(joinEvent(statusId, participationMessage))?.then(() => {
dispatch(joinEvent(statusId, participationMessage)).then(() => {
onClose();
}).catch(() => {});
};

View File

@ -29,6 +29,7 @@ import { Layout } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } 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 GroupsPage from 'soapbox/pages/groups_page';
// import GroupPage from 'soapbox/pages/group_page';
import HomePage from 'soapbox/pages/home_page';
@ -113,6 +114,8 @@ import {
TestTimeline,
LogoutPage,
AuthTokenList,
EventInformation,
EventDiscussion,
} from './util/async-components';
import { WrappedRoute } from './util/react_router_helpers';
@ -280,6 +283,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/@:username/favorites' component={FavouritedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/pins' component={PinnedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/posts/:statusId' publicRoute exact page={StatusPage} component={Status} content={children} />
<WrappedRoute path='/@:username/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 />

View File

@ -533,3 +533,15 @@ export function CreateEventModal() {
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');
}

View File

@ -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={3} key='trends-panel' />}
</BundleContainer>
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />
</Layout.Aside>
</>
);
};
export default EventPage;

View File

@ -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,29 +28,35 @@ 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,
} from 'soapbox/actions/events';
import {
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
} from '../actions/familiar_followers';
} from 'soapbox/actions/familiar_followers';
import {
GROUP_MEMBERS_FETCH_SUCCESS,
GROUP_MEMBERS_EXPAND_SUCCESS,
GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS,
GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS,
GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS,
} from '../actions/groups';
} from 'soapbox/actions/groups';
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';
@ -113,7 +119,6 @@ type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
return state.setIn(path, ListRecord({
next,
items: ImmutableOrderedSet(accounts.map(item => item.id)),
@ -202,6 +207,27 @@ 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_participations', 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_participations', action.id, 'items'],
(items) => (items as ImmutableOrderedSet<ParticipationRequest>)
.union(action.participations.map(({ account, participation_message }: APIEntity) => ParticipationRequestRecord({
account: account.id,
participation_message,
}))),
);
default:
return state;
}

View File

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