Implement new design for "Refetch all" (#36172)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
Haml Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Crowdin / Upload translations / upload-translations (push) Has been cancelled

This commit is contained in:
diondiondion 2025-09-24 11:54:07 +02:00 committed by GitHub
parent 29d9f81e42
commit 3a81ee8f5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 254 additions and 50 deletions

View File

@ -8,6 +8,7 @@ const meta = {
component: Alert, component: Alert,
args: { args: {
isActive: true, isActive: true,
isLoading: false,
animateFrom: 'side', animateFrom: 'side',
title: '', title: '',
message: '', message: '',
@ -20,6 +21,12 @@ const meta = {
type: 'boolean', type: 'boolean',
description: 'Animate to the active (displayed) state of the alert', 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: { animateFrom: {
control: 'radio', control: 'radio',
type: 'string', type: 'string',
@ -108,3 +115,11 @@ export const InSizedContainer: Story = {
</div> </div>
), ),
}; };
export const WithLoadingIndicator: Story = {
args: {
...WithDismissButton.args,
isLoading: true,
},
render: InSizedContainer.render,
};

View File

@ -3,6 +3,7 @@ import { useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { IconButton } from '../icon_button'; import { IconButton } from '../icon_button';
@ -10,21 +11,23 @@ import { IconButton } from '../icon_button';
* Snackbar/Toast-style notification component. * Snackbar/Toast-style notification component.
*/ */
export const Alert: React.FC<{ export const Alert: React.FC<{
isActive?: boolean;
animateFrom?: 'side' | 'below';
title?: string; title?: string;
message: string; message: string;
action?: string; action?: string;
onActionClick?: () => void; onActionClick?: () => void;
onDismiss?: () => void; onDismiss?: () => void;
isActive?: boolean;
isLoading?: boolean;
animateFrom?: 'side' | 'below';
}> = ({ }> = ({
isActive,
animateFrom = 'side',
title, title,
message, message,
action, action,
onActionClick, onActionClick,
onDismiss, onDismiss,
isActive,
isLoading,
animateFrom = 'side',
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -51,7 +54,13 @@ export const Alert: React.FC<{
</button> </button>
)} )}
{onDismiss && ( {isLoading && (
<span className='notification-bar__loading-indicator'>
<LoadingIndicator />
</span>
)}
{onDismiss && !isLoading && (
<IconButton <IconButton
title={intl.formatMessage({ title={intl.formatMessage({
id: 'dismissable_banner.dismiss', id: 'dismissable_banner.dismiss',

View File

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
/**
* A helper component for managing the rendering of components that
* need to stay in the DOM a bit longer to finish their CSS exit animation.
*
* In the future, replace this component with plain CSS once that is feasible.
* This will require broader support for `transition-behavior: allow-discrete`
* and https://developer.mozilla.org/en-US/docs/Web/CSS/overlay.
*/
export const ExitAnimationWrapper: React.FC<{
/**
* Set this to true to indicate that the nested component should be rendered
*/
isActive: boolean;
/**
* How long the component should be rendered after `isActive` was set to `false`
*/
delayMs?: number;
/**
* Set this to true to also delay the entry of the nested component until after
* another one has exited full.
*/
withEntryDelay?: boolean;
/**
* Render prop that provides the nested component with the `delayedIsActive` flag
*/
children: (delayedIsActive: boolean) => 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);
};

View File

@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { import {
fetchContext, fetchContext,
@ -8,31 +8,80 @@ import {
} from 'mastodon/actions/statuses'; } from 'mastodon/actions/statuses';
import type { AsyncRefreshHeader } from 'mastodon/api'; import type { AsyncRefreshHeader } from 'mastodon/api';
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes'; 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 { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
const AnimatedAlert: React.FC<
React.ComponentPropsWithoutRef<typeof Alert> & { withEntryDelay?: boolean }
> = ({ isActive = false, withEntryDelay, ...props }) => (
<ExitAnimationWrapper withEntryDelay isActive={isActive}>
{(delayedIsActive) => <Alert isActive={delayedIsActive} {...props} />}
</ExitAnimationWrapper>
);
const messages = defineMessages({ 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', 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<{ export const RefreshController: React.FC<{
statusId: string; statusId: string;
}> = ({ statusId }) => { }> = ({ statusId }) => {
const refresh = useAppSelector( const refresh = useAppSelector(
(state) => state.contexts.refreshing[statusId], (state) => state.contexts.refreshing[statusId],
); );
const autoRefresh = useAppSelector( const currentReplyCount = useAppSelector(
(state) => (state) => state.contexts.replies[statusId]?.length ?? 0,
!state.contexts.replies[statusId] ||
state.contexts.replies[statusId].length === 0,
); );
const autoRefresh = !currentReplyCount;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false); const [loadingState, setLoadingState] = useState<LoadingState>(
refresh && autoRefresh ? 'loading-initial' : 'idle',
);
const [wasDismissed, setWasDismissed] = useState(false);
const dismissPrompt = useCallback(() => {
setWasDismissed(true);
setLoadingState('idle');
}, []);
useEffect(() => { useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>; let timeoutId: ReturnType<typeof setTimeout>;
@ -45,67 +94,104 @@ export const RefreshController: React.FC<{
if (result.async_refresh.result_count > 0) { if (result.async_refresh.result_count > 0) {
if (autoRefresh) { if (autoRefresh) {
void dispatch(fetchContext({ statusId })); void dispatch(fetchContext({ statusId })).then(() => {
return ''; setLoadingState('idle');
});
} else {
setLoadingState('more-available');
} }
} else {
setReady(true); setLoadingState('idle');
} }
} else { } else {
scheduleRefresh(refresh); scheduleRefresh(refresh);
} }
return '';
}); });
}, refresh.retry * 1000); }, refresh.retry * 1000);
}; };
if (refresh) { if (refresh && !wasDismissed) {
scheduleRefresh(refresh); scheduleRefresh(refresh);
setLoadingState('loading-initial');
} }
return () => { return () => {
clearTimeout(timeoutId); 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(() => { const handleClick = useCallback(() => {
setLoading(true); setLoadingState('loading-more');
setReady(false);
dispatch(fetchContext({ statusId })) dispatch(fetchContext({ statusId }))
.then(() => { .then(() => {
setLoading(false); setLoadingState('success');
return ''; return '';
}) })
.catch(() => { .catch(() => {
setLoading(false); setLoadingState('error');
}); });
}, [dispatch, setReady, statusId]); }, [dispatch, statusId]);
if (ready && !loading) { if (loadingState === 'loading-initial') {
return ( return (
<button className='load-more load-gap' onClick={handleClick}> <div
<FormattedMessage className='load-more load-gap'
id='status.context.load_new_replies' aria-busy
defaultMessage='New replies available' aria-live='polite'
/> aria-label={intl.formatMessage(messages.loadingInitial)}
</button> >
<LoadingIndicator />
</div>
); );
} }
if (!refresh && !loading) {
return null;
}
return ( return (
<div <div className='column__alert' role='status' aria-live='polite'>
className='load-more load-gap' <AnimatedAlert
aria-busy isActive={loadingState === 'more-available'}
aria-live='polite' message={intl.formatMessage(messages.moreFound)}
aria-label={intl.formatMessage(messages.loading)} action={intl.formatMessage(messages.show)}
> onActionClick={handleClick}
<LoadingIndicator /> onDismiss={dismissPrompt}
animateFrom='below'
/>
<AnimatedAlert
isLoading
withEntryDelay
isActive={loadingState === 'loading-more'}
message={intl.formatMessage(messages.loadingMore)}
animateFrom='below'
/>
<AnimatedAlert
withEntryDelay
isActive={loadingState === 'error'}
message={intl.formatMessage(messages.error)}
action={intl.formatMessage(messages.retry)}
onActionClick={handleClick}
onDismiss={dismissPrompt}
animateFrom='below'
/>
<AnimatedAlert
withEntryDelay
isActive={loadingState === 'success'}
message={intl.formatMessage(messages.success)}
animateFrom='below'
/>
</div> </div>
); );
}; };

View File

@ -648,8 +648,8 @@ class Status extends ImmutablePureComponent {
</div> </div>
</Hotkeys> </Hotkeys>
{remoteHint}
{descendants} {descendants}
{remoteHint}
</div> </div>
</ScrollContainer> </ScrollContainer>

View File

@ -865,8 +865,13 @@
"status.cannot_quote": "You are not allowed to quote this post", "status.cannot_quote": "You are not allowed to quote this post",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.contains_quote": "Contains quote", "status.contains_quote": "Contains quote",
"status.context.load_new_replies": "New replies available", "status.context.loading": "Loading more replies",
"status.context.loading": "Checking for 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.continued_thread": "Continued thread",
"status.copy": "Copy link to post", "status.copy": "Copy link to post",
"status.delete": "Delete", "status.delete": "Delete",

View File

@ -2969,7 +2969,6 @@ a.account__display-name {
flex: 1 1 auto; flex: 1 1 auto;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
overflow-x: auto;
position: relative; position: relative;
&.unscrollable { &.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 { .ui {
--mobile-bottom-nav-height: 55px; --mobile-bottom-nav-height: 55px;
--last-content-item-border-width: 2px; --last-content-item-border-width: 2px;
@ -3185,7 +3207,6 @@ a.account__display-name {
.column, .column,
.drawer { .drawer {
flex: 1 1 100%; flex: 1 1 100%;
overflow: hidden;
} }
@media screen and (width > $mobile-breakpoint) { @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 { .hashtag-header {
border-bottom: 1px solid var(--background-border-color); border-bottom: 1px solid var(--background-border-color);
padding: 15px; padding: 15px;