diff --git a/app/javascript/mastodon/components/alert/alert.stories.tsx b/app/javascript/mastodon/components/alert/alert.stories.tsx index 4d5f8acb65b..f12f06751d7 100644 --- a/app/javascript/mastodon/components/alert/alert.stories.tsx +++ b/app/javascript/mastodon/components/alert/alert.stories.tsx @@ -8,6 +8,7 @@ const meta = { component: Alert, args: { isActive: true, + isLoading: false, animateFrom: 'side', title: '', message: '', @@ -20,6 +21,12 @@ const meta = { type: 'boolean', description: 'Animate to the active (displayed) state of the alert', }, + isLoading: { + control: 'boolean', + type: 'boolean', + description: + 'Display a loading indicator in the alert, replacing the dismiss button if present', + }, animateFrom: { control: 'radio', type: 'string', @@ -108,3 +115,11 @@ export const InSizedContainer: Story = { ), }; + +export const WithLoadingIndicator: Story = { + args: { + ...WithDismissButton.args, + isLoading: true, + }, + render: InSizedContainer.render, +}; diff --git a/app/javascript/mastodon/components/alert/index.tsx b/app/javascript/mastodon/components/alert/index.tsx index 1009e77524b..72fee0a4a30 100644 --- a/app/javascript/mastodon/components/alert/index.tsx +++ b/app/javascript/mastodon/components/alert/index.tsx @@ -3,6 +3,7 @@ import { useIntl } from 'react-intl'; import classNames from 'classnames'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { IconButton } from '../icon_button'; @@ -10,21 +11,23 @@ import { IconButton } from '../icon_button'; * Snackbar/Toast-style notification component. */ export const Alert: React.FC<{ - isActive?: boolean; - animateFrom?: 'side' | 'below'; title?: string; message: string; action?: string; onActionClick?: () => void; onDismiss?: () => void; + isActive?: boolean; + isLoading?: boolean; + animateFrom?: 'side' | 'below'; }> = ({ - isActive, - animateFrom = 'side', title, message, action, onActionClick, onDismiss, + isActive, + isLoading, + animateFrom = 'side', }) => { const intl = useIntl(); @@ -51,7 +54,13 @@ export const Alert: React.FC<{ )} - {onDismiss && ( + {isLoading && ( + + + + )} + + {onDismiss && !isLoading && ( React.ReactNode; +}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { + const [delayedIsActive, setDelayedIsActive] = useState(false); + + useEffect(() => { + if (isActive && !withEntryDelay) { + setDelayedIsActive(true); + + return () => ''; + } else { + const timeout = setTimeout(() => { + setDelayedIsActive(isActive); + }, delayMs); + + return () => { + clearTimeout(timeout); + }; + } + }, [isActive, delayMs, withEntryDelay]); + + if (!isActive && !delayedIsActive) { + return null; + } + + return children(isActive && delayedIsActive); +}; diff --git a/app/javascript/mastodon/features/status/components/refresh_controller.tsx b/app/javascript/mastodon/features/status/components/refresh_controller.tsx index 9788b2849f3..34faaf1d5d2 100644 --- a/app/javascript/mastodon/features/status/components/refresh_controller.tsx +++ b/app/javascript/mastodon/features/status/components/refresh_controller.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages } from 'react-intl'; import { fetchContext, @@ -8,31 +8,80 @@ import { } from 'mastodon/actions/statuses'; import type { AsyncRefreshHeader } from 'mastodon/api'; import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes'; +import { Alert } from 'mastodon/components/alert'; +import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +const AnimatedAlert: React.FC< + React.ComponentPropsWithoutRef & { withEntryDelay?: boolean } +> = ({ isActive = false, withEntryDelay, ...props }) => ( + + {(delayedIsActive) => } + +); + const messages = defineMessages({ - loading: { + moreFound: { + id: 'status.context.more_replies_found', + defaultMessage: 'More replies found', + }, + show: { + id: 'status.context.show', + defaultMessage: 'Show', + }, + loadingInitial: { id: 'status.context.loading', - defaultMessage: 'Checking for more replies', + defaultMessage: 'Loading', + }, + loadingMore: { + id: 'status.context.loading_more', + defaultMessage: 'Loading more replies', + }, + success: { + id: 'status.context.loading_success', + defaultMessage: 'All replies loaded', + }, + error: { + id: 'status.context.loading_error', + defaultMessage: "Couldn't load new replies", + }, + retry: { + id: 'status.context.retry', + defaultMessage: 'Retry', }, }); +type LoadingState = + | 'idle' + | 'more-available' + | 'loading-initial' + | 'loading-more' + | 'success' + | 'error'; + export const RefreshController: React.FC<{ statusId: string; }> = ({ statusId }) => { const refresh = useAppSelector( (state) => state.contexts.refreshing[statusId], ); - const autoRefresh = useAppSelector( - (state) => - !state.contexts.replies[statusId] || - state.contexts.replies[statusId].length === 0, + const currentReplyCount = useAppSelector( + (state) => state.contexts.replies[statusId]?.length ?? 0, ); + const autoRefresh = !currentReplyCount; const dispatch = useAppDispatch(); const intl = useIntl(); - const [ready, setReady] = useState(false); - const [loading, setLoading] = useState(false); + + const [loadingState, setLoadingState] = useState( + refresh && autoRefresh ? 'loading-initial' : 'idle', + ); + + const [wasDismissed, setWasDismissed] = useState(false); + const dismissPrompt = useCallback(() => { + setWasDismissed(true); + setLoadingState('idle'); + }, []); useEffect(() => { let timeoutId: ReturnType; @@ -45,67 +94,104 @@ export const RefreshController: React.FC<{ if (result.async_refresh.result_count > 0) { if (autoRefresh) { - void dispatch(fetchContext({ statusId })); - return ''; + void dispatch(fetchContext({ statusId })).then(() => { + setLoadingState('idle'); + }); + } else { + setLoadingState('more-available'); } - - setReady(true); + } else { + setLoadingState('idle'); } } else { scheduleRefresh(refresh); } - - return ''; }); }, refresh.retry * 1000); }; - if (refresh) { + if (refresh && !wasDismissed) { scheduleRefresh(refresh); + setLoadingState('loading-initial'); } return () => { clearTimeout(timeoutId); }; - }, [dispatch, setReady, statusId, refresh, autoRefresh]); + }, [dispatch, statusId, refresh, autoRefresh, wasDismissed]); + + useEffect(() => { + // Hide success message after a short delay + if (loadingState === 'success') { + const timeoutId = setTimeout(() => { + setLoadingState('idle'); + }, 3000); + + return () => { + clearTimeout(timeoutId); + }; + } + return () => ''; + }, [loadingState]); const handleClick = useCallback(() => { - setLoading(true); - setReady(false); + setLoadingState('loading-more'); dispatch(fetchContext({ statusId })) .then(() => { - setLoading(false); + setLoadingState('success'); return ''; }) .catch(() => { - setLoading(false); + setLoadingState('error'); }); - }, [dispatch, setReady, statusId]); + }, [dispatch, statusId]); - if (ready && !loading) { + if (loadingState === 'loading-initial') { return ( - +
+ +
); } - if (!refresh && !loading) { - return null; - } - return ( -
- +
+ + + +
); }; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index fb8f3d81d91..404faf609e4 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -648,8 +648,8 @@ class Status extends ImmutablePureComponent {
- {remoteHint} {descendants} + {remoteHint} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 7721cc36d3f..f949c303396 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -865,8 +865,13 @@ "status.cannot_quote": "You are not allowed to quote this post", "status.cannot_reblog": "This post cannot be boosted", "status.contains_quote": "Contains quote", - "status.context.load_new_replies": "New replies available", - "status.context.loading": "Checking for more replies", + "status.context.loading": "Loading more replies", + "status.context.loading_error": "Couldn't load new replies", + "status.context.loading_more": "Loading more replies", + "status.context.loading_success": "All replies loaded", + "status.context.more_replies_found": "More replies found", + "status.context.retry": "Retry", + "status.context.show": "Show", "status.continued_thread": "Continued thread", "status.copy": "Copy link to post", "status.delete": "Delete", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 079985c404d..d893a358367 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2969,7 +2969,6 @@ a.account__display-name { flex: 1 1 auto; flex-direction: row; justify-content: flex-start; - overflow-x: auto; position: relative; &.unscrollable { @@ -3145,6 +3144,29 @@ a.account__display-name { } } +.column__alert { + position: sticky; + bottom: 1rem; + z-index: 10; + box-sizing: border-box; + display: grid; + width: 100%; + max-width: 360px; + padding-inline: 10px; + margin-top: 1rem; + margin-inline: auto; + + @media (max-width: #{$mobile-menu-breakpoint - 1}) { + bottom: 4rem; + } + + & > * { + // Make all nested alerts occupy the same space + // rather than stack + grid-area: 1 / 1; + } +} + .ui { --mobile-bottom-nav-height: 55px; --last-content-item-border-width: 2px; @@ -3185,7 +3207,6 @@ a.account__display-name { .column, .drawer { flex: 1 1 100%; - overflow: hidden; } @media screen and (width > $mobile-breakpoint) { @@ -10397,6 +10418,21 @@ noscript { } } +.notification-bar__loading-indicator { + --spinner-size: 22px; + + position: relative; + height: var(--spinner-size); + width: var(--spinner-size); + margin-inline-start: 2px; + + svg { + color: $white; + height: var(--spinner-size); + width: var(--spinner-size); + } +} + .hashtag-header { border-bottom: 1px solid var(--background-border-color); padding: 15px;