From f7c09461fdeb0fe8f0d47669c5ec83660fe39c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 8 Sep 2022 23:25:02 +0200 Subject: [PATCH] Event pages? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/events.ts | 205 +++++++++++++++- app/soapbox/components/event-preview.tsx | 131 +--------- app/soapbox/components/status-media.tsx | 19 +- app/soapbox/components/ui/layout/layout.tsx | 2 +- app/soapbox/components/ui/stack/stack.tsx | 6 +- .../admin/components/report_status.tsx | 2 +- app/soapbox/features/admin/tabs/dashboard.tsx | 15 +- .../event/components/event-action-button.tsx | 88 +++++++ .../features/event/components/event-date.tsx | 58 +++++ .../event/components/event-header.tsx | 163 +++++++++++++ .../features/event/event-discussion.tsx | 227 ++++++++++++++++++ .../features/event/event-information.tsx | 69 ++++++ .../components/placeholder_event_header.tsx | 26 ++ .../report/components/status_check_box.tsx | 6 +- .../ui/components/modals/join-event-modal.tsx | 2 +- app/soapbox/features/ui/index.tsx | 5 + .../features/ui/util/async-components.ts | 12 + app/soapbox/pages/event_page.tsx | 108 +++++++++ app/soapbox/reducers/user_lists.ts | 44 +++- app/soapbox/utils/download.ts | 13 + 20 files changed, 1037 insertions(+), 164 deletions(-) create mode 100644 app/soapbox/features/event/components/event-action-button.tsx create mode 100644 app/soapbox/features/event/components/event-date.tsx create mode 100644 app/soapbox/features/event/components/event-header.tsx create mode 100644 app/soapbox/features/event/event-discussion.tsx create mode 100644 app/soapbox/features/event/event-information.tsx create mode 100644 app/soapbox/features/placeholder/components/placeholder_event_header.tsx create mode 100644 app/soapbox/pages/event_page.tsx create mode 100644 app/soapbox/utils/download.ts diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts index ac2d1fda2..711dd6a63 100644 --- a/app/soapbox/actions/events.ts +++ b/app/soapbox/actions/events.ts @@ -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' }, @@ -184,7 +202,7 @@ const submitEvent = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const name = state.create_event.name; + const name = state.create_event.name; const status = state.create_event.status; const banner = state.create_event.banner; const startTime = state.create_event.start_time; @@ -205,8 +223,8 @@ const submitEvent = () => join_mode: joinMode, }; - if (endTime) params.end_time = endTime; - if (banner) params.banner_id = banner.id; + if (endTime) params.end_time = endTime; + if (banner) params.banner_id = banner.id; if (location) params.location_id = location.origin_id; return api(getState).post('/api/v1/pleroma/events', params).then(({ data }) => { @@ -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, }; diff --git a/app/soapbox/components/event-preview.tsx b/app/soapbox/components/event-preview.tsx index e1da5af62..5999ca44e 100644 --- a/app/soapbox/components/event-preview.tsx +++ b/app/soapbox/components/event-preview.tsx @@ -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 = ({ status }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); const me = useAppSelector((state) => state.me); @@ -32,112 +31,6 @@ const EventPreview: React.FC = ({ status }) => { const banner = status.media_attachments?.find(({ description }) => description === 'Banner'); - const handleJoin: React.EventHandler = (e) => { - e.preventDefault(); - - if (event.join_mode === 'free') { - dispatch(joinEvent(status.id)); - } else { - dispatch(openModal('JOIN_EVENT', { - statusId: status.id, - })); - } - }; - - const handleLeave: React.EventHandler = (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 = ( - <> - - {' - '} - - - ); - } else { - date = ( - <> - - {' - '} - - - ); - } - } else { - date = ( - - ); - } - - return ( - - - {date} - - ); - }, [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 = ; - buttonIcon = require('@tabler/icons/check.svg'); - break; - case 'pending': - buttonLabel = ; - break; - case 'reject': - buttonLabel = ; - buttonIcon = require('@tabler/icons/ban.svg'); - buttonDisabled = true; - break; - default: - buttonLabel = ; - buttonAction = handleJoin; - } - - return ( - - ); - }, [event.join_state]); - return (
@@ -149,16 +42,16 @@ const EventPreview: React.FC = ({ status }) => { > - ) : renderAction()} + ) : }
- {banner && {banner.url}} + {banner && {intl.formatMessage(messages.bannerHeader)}}
{event.name}
- + @@ -166,10 +59,10 @@ const EventPreview: React.FC = ({ status }) => { - {renderDate()} + {event.location && ( - + {event.location.get('name')} diff --git a/app/soapbox/components/status-media.tsx b/app/soapbox/components/status-media.tsx index d64d88e4b..9a0c007e1 100644 --- a/app/soapbox/components/status-media.tsx +++ b/app/soapbox/components/status-media.tsx @@ -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 = ({ onClick, showMedia = true, onToggleVisibility = () => {}, + excludeBanner = false, }) => { const dispatch = useAppDispatch(); const [mediaWrapperWidth, setMediaWrapperWidth] = useState(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 = ({ if (muted) { media = ( @@ -99,7 +104,7 @@ const StatusMedia: React.FC = ({ ); } else { media = ( - + {(Component: any) => ( = ({ const attachment = firstAttachment; media = ( - + {(Component: any) => ( = ({ {(Component: any) => ( > = ({ children, classN /** Right sidebar container in the UI. */ const Aside: React.FC = ({ children }) => ( diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 3398d8df2..640022462 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -21,13 +21,15 @@ const justifyContentOptions = { const alignItemsOptions = { center: 'items-center', + start: 'items-start', + end: 'items-end', }; interface IStack extends React.HTMLAttributes { /** 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
element. */ @@ -43,7 +45,7 @@ const Stack: React.FC = (props) => { return (
= ({ status }) => { const video = firstAttachment; return ( - + {(Component: any) => ( { - 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); diff --git a/app/soapbox/features/event/components/event-action-button.tsx b/app/soapbox/features/event/components/event-action-button.tsx new file mode 100644 index 000000000..0b72ec8ee --- /dev/null +++ b/app/soapbox/features/event/components/event-action-button.tsx @@ -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 = ({ status }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const event = status.event!; + + const handleJoin: React.EventHandler = (e) => { + e.preventDefault(); + + if (event.join_mode === 'free') { + dispatch(joinEvent(status.id)); + } else { + dispatch(openModal('JOIN_EVENT', { + statusId: status.id, + })); + } + }; + + const handleLeave: React.EventHandler = (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 = ; + buttonIcon = require('@tabler/icons/check.svg'); + break; + case 'pending': + buttonLabel = ; + break; + case 'reject': + buttonLabel = ; + buttonIcon = require('@tabler/icons/ban.svg'); + buttonDisabled = true; + break; + default: + buttonLabel = ; + buttonAction = handleJoin; + } + + return ( + + ); +}; + +export default EventActionButton; diff --git a/app/soapbox/features/event/components/event-date.tsx b/app/soapbox/features/event/components/event-date.tsx new file mode 100644 index 000000000..130d818d4 --- /dev/null +++ b/app/soapbox/features/event/components/event-date.tsx @@ -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 = ({ 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 = ( + <> + + {' - '} + + + ); + } else { + date = ( + <> + + {' - '} + + + ); + } + } else { + date = ( + + ); + } + + return ( + + + {date} + + ); +}; + +export default EventDate; diff --git a/app/soapbox/features/event/components/event-header.tsx b/app/soapbox/features/event/components/event-header.tsx new file mode 100644 index 000000000..3492d1739 --- /dev/null +++ b/app/soapbox/features/event/components/event-header.tsx @@ -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 = ({ status }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const me = useAppSelector(state => state.me); + + if (!status || !status.event) { + return ( + <> +
+
+
+ + + + ); + } + + const account = status.account as AccountEntity; + const event = status.event; + const banner = status.media_attachments?.find(({ description }) => description === 'Banner'); + + const handleHeaderClick: React.MouseEventHandler = (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 ( + <> +
+
+ {banner && ( + + + + )} +
+
+ + + {event.name} + + + + + {menu.map((menuItem, idx) => { + if (typeof menuItem?.text === 'undefined') { + return ; + } 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 ( + +
+ {menuItem.icon && ( + + )} + +
{menuItem.text}
+
+
+ ); + } + })} +
+
+ {account.id !== me && } +
+ + + + + + + + {account.verified && } + + ), + }} + /> + + + + + + {event.location && ( + + + + {event.location.get('name')} + + + )} + +
+ + ); +}; + +export default EventHeader; diff --git a/app/soapbox/features/event/event-discussion.tsx b/app/soapbox/features/event/event-discussion.tsx new file mode 100644 index 000000000..62c90ae65 --- /dev/null +++ b/app/soapbox/features/event/event-discussion.tsx @@ -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(); + 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, index: number) => void, + onOpenVideo: (video: AttachmentEntity, time: number) => void, +} + +const Thread: React.FC = (props) => { + const dispatch = useAppDispatch(); + + const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); + + const descendantsIds = useAppSelector(state => { + let descendantsIds = ImmutableOrderedSet(); + + if (status) { + const statusId = status.id; + descendantsIds = getDescendantsIds(state, statusId); + descendantsIds = descendantsIds.delete(statusId); + } + + return descendantsIds; + }); + + const [isLoaded, setIsLoaded] = useState(!!status); + const [next, setNext] = useState(); + + const node = useRef(null); + const scroller = useRef(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(`#thread [data-index="${index}"] .focusable`); + + if (element) { + element.focus(); + } + }, + }); + }; + + const renderTombstone = (id: string) => { + return ( +
+ +
+ ); + }; + + const renderStatus = (id: string) => { + return ( + + ); + }; + + const renderPendingStatus = (id: string) => { + const idempotencyKey = id.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderChildren = (list: ImmutableOrderedSet) => { + 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 ( + + ); + } else if (!status) { + return ( + + ); + } + + const children: JSX.Element[] = []; + + if (hasDescendants) { + children.push(...renderChildren(descendantsIds).toArray()); + } + + return ( + + +
+ } + initialTopMostItemIndex={0} + > + {children} + +
+
+
+ ); +}; + +export default Thread; diff --git a/app/soapbox/features/event/event-information.tsx b/app/soapbox/features/event/event-information.tsx new file mode 100644 index 000000000..d3643bc05 --- /dev/null +++ b/app/soapbox/features/event/event-information.tsx @@ -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 = ({ 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(!!status); + const [showMedia, setShowMedia] = useState(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 ( + + ); + } else if (!status) return null; + + return ( + + + + + + ); +}; + +export default EventInformation; diff --git a/app/soapbox/features/placeholder/components/placeholder_event_header.tsx b/app/soapbox/features/placeholder/components/placeholder_event_header.tsx new file mode 100644 index 000000000..c01272944 --- /dev/null +++ b/app/soapbox/features/placeholder/components/placeholder_event_header.tsx @@ -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 ( + +

{generateText(eventNameLength)}

+ + +

{generateText(organizerNameLength)}

+

{generateText(dateLength)}

+

{generateText(locationLength)}

+
+
+ ); +}; + +export default PlaceholderEventHeader; diff --git a/app/soapbox/features/report/components/status_check_box.tsx b/app/soapbox/features/report/components/status_check_box.tsx index cb2424ea7..3d823c120 100644 --- a/app/soapbox/features/report/components/status_check_box.tsx +++ b/app/soapbox/features/report/components/status_check_box.tsx @@ -35,7 +35,7 @@ const StatusCheckBox: React.FC = ({ id, disabled }) => { if (video) { media = ( - + {(Component: any) => ( = ({ id, disabled }) => { if (audio) { media = ( - + {(Component: any) => ( = ({ id, disabled }) => { } } else { media = ( - + {(Component: any) => } ); diff --git a/app/soapbox/features/ui/components/modals/join-event-modal.tsx b/app/soapbox/features/ui/components/modals/join-event-modal.tsx index c3a7743fa..e7014bbbf 100644 --- a/app/soapbox/features/ui/components/modals/join-event-modal.tsx +++ b/app/soapbox/features/ui/components/modals/join-event-modal.tsx @@ -32,7 +32,7 @@ const AccountNoteModal: React.FC = ({ statusId }) => { const handleSubmit = () => { setIsSubmitting(true); - dispatch(joinEvent(statusId, participationMessage))?.then(() => { + dispatch(joinEvent(statusId, participationMessage)).then(() => { onClose(); }).catch(() => {}); }; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index e7c0f74e2..2dc454371 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -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 }) => { + + diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 78353c3d8..ba1cca62e 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -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'); +} diff --git a/app/soapbox/pages/event_page.tsx b/app/soapbox/pages/event_page.tsx new file mode 100644 index 000000000..b243dc5a1 --- /dev/null +++ b/app/soapbox/pages/event_page.tsx @@ -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 = ({ 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 ( + + ); + } + + 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 ( + <> + + +
+ + {Component => } + + + {status && showTabs && ( + + )} + + {children} +
+
+ + {!me && ( + + {Component => } + + )} +
+ + + {!me && ( + + {Component => } + + )} + {features.trends && ( + + {Component => } + + )} + {features.suggestions && ( + + {Component => } + + )} + + + + ); +}; + +export default EventPage; diff --git a/app/soapbox/reducers/user_lists.ts b/app/soapbox/reducers/user_lists.ts index c6527222f..421b0c37d 100644 --- a/app/soapbox/reducers/user_lists.ts +++ b/app/soapbox/reducers/user_lists.ts @@ -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) + .union(action.participations.map(({ account, participation_message }: APIEntity) => ParticipationRequestRecord({ + account: account.id, + participation_message, + }))), + ); default: return state; } diff --git a/app/soapbox/utils/download.ts b/app/soapbox/utils/download.ts new file mode 100644 index 000000000..c877bee25 --- /dev/null +++ b/app/soapbox/utils/download.ts @@ -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(); +};