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

@@ -59,7 +59,7 @@ const ReportStatus: React.FC<IReportStatus> = ({ status }) => {
const video = firstAttachment;
return (
<Bundle fetchComponent={Video} >
<Bundle fetchComponent={Video}>
{(Component: any) => (
<Component
preview={video.preview_url}

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

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

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