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,
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 = {
</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 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<{
</button>
)}
{onDismiss && (
{isLoading && (
<span className='notification-bar__loading-indicator'>
<LoadingIndicator />
</span>
)}
{onDismiss && !isLoading && (
<IconButton
title={intl.formatMessage({
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 { 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<typeof Alert> & { withEntryDelay?: boolean }
> = ({ isActive = false, withEntryDelay, ...props }) => (
<ExitAnimationWrapper withEntryDelay isActive={isActive}>
{(delayedIsActive) => <Alert isActive={delayedIsActive} {...props} />}
</ExitAnimationWrapper>
);
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<LoadingState>(
refresh && autoRefresh ? 'loading-initial' : 'idle',
);
const [wasDismissed, setWasDismissed] = useState(false);
const dismissPrompt = useCallback(() => {
setWasDismissed(true);
setLoadingState('idle');
}, []);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
@ -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 (
<button className='load-more load-gap' onClick={handleClick}>
<FormattedMessage
id='status.context.load_new_replies'
defaultMessage='New replies available'
/>
</button>
<div
className='load-more load-gap'
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loadingInitial)}
>
<LoadingIndicator />
</div>
);
}
if (!refresh && !loading) {
return null;
}
return (
<div
className='load-more load-gap'
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loading)}
>
<LoadingIndicator />
<div className='column__alert' role='status' aria-live='polite'>
<AnimatedAlert
isActive={loadingState === 'more-available'}
message={intl.formatMessage(messages.moreFound)}
action={intl.formatMessage(messages.show)}
onActionClick={handleClick}
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>
);
};

View File

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

View File

@ -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",

View File

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