Merge ProgressCircle into SpinnerV2

This commit is contained in:
Fedor Indutny
2025-08-29 11:55:52 -07:00
committed by GitHub
parent 418a0a0aa3
commit f0488dff25
27 changed files with 349 additions and 493 deletions

View File

@@ -2792,13 +2792,6 @@ button.ConversationDetails__action-button {
.module-image__progress-circle-wrapper {
@include mixins.position-absolute-center;
.ProgressCircle .ProgressCircle__background {
stroke: variables.$color-white-alpha-20;
}
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white;
}
}
.module-image__spinner-container {
@@ -2954,7 +2947,7 @@ button.module-image__border-overlay:focus {
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/play/play-fill.svg',
variables.$color-white
var(--color-label-primary-on-color)
);
}
.module-image__stop-icon {
@@ -2965,7 +2958,7 @@ button.module-image__border-overlay:focus {
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/stop/stop-fill.svg',
variables.$color-white
var(--color-label-primary-on-color)
);
}
.module-image__download-icon {
@@ -2975,7 +2968,7 @@ button.module-image__border-overlay:focus {
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/arrow/arrow-down.svg',
variables.$color-white
var(--color-label-primary-on-color)
);
}
.module-image__undownloadable-icon {
@@ -2985,7 +2978,7 @@ button.module-image__border-overlay:focus {
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/photo/photo-slash-compact.svg',
variables.$color-white
var(--color-label-primary-on-color)
);
}

View File

@@ -25,7 +25,7 @@
z-index: variables.$z-index-above-base;
@include mixins.font-caption;
color: variables.$color-white;
color: var(--color-label-primary-on-color);
transition: width 400ms ease-out;
}
@@ -34,21 +34,6 @@
position: relative;
margin: 4px;
margin-inline-end: -4px;
.ProgressCircle .ProgressCircle__background {
stroke: variables.$color-white-alpha-20;
}
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white;
}
.module-spinner__circle {
background-color: variables.$color-white-alpha-20;
}
.module-spinner__arc {
background-color: variables.$color-white;
}
}
.AttachmentDetailPill__text-wrapper {
@@ -72,7 +57,7 @@
width: 12px;
@include mixins.color-svg(
'../images/icons/v3/stop/stop-fill.svg',
variables.$color-white
var(--color-label-primary-on-color)
);
}
.AttachmentDetailPill__download-icon {
@@ -82,6 +67,6 @@
width: 16px;
@include mixins.color-svg(
'../images/icons/v3/arrow/arrow-down.svg',
variables.$color-white
var(--color-label-primary-on-color)
);
}

View File

@@ -111,54 +111,22 @@
.AttachmentStatusIcon__circle-icon--x {
@include mixins.color-svg-themed(
'../images/icons/v3/x/x-bold.svg',
variables.$color-white,
variables.$color-white-alpha-90
var(--color-label-primary-on-color),
var(--color-label-primary-on-color)
);
}
.AttachmentStatusIcon__circle-icon--arrow-down {
@include mixins.color-svg-themed(
'../images/icons/v3/arrow/arrow-down-bold.svg',
variables.$color-white,
variables.$color-white-alpha-90
var(--color-label-primary-on-color),
var(--color-label-primary-on-color)
);
}
.AttachmentStatusIcon__circle-icon--incoming {
@include mixins.light-theme {
background-color: variables.$color-gray-90;
background-color: var(--color-label-primary);
}
@include mixins.dark-theme {
background-color: variables.$color-white-alpha-90;
}
}
.AttachmentStatusIcon__progress-container {
.ProgressCircle .ProgressCircle__background {
@include mixins.light-theme {
stroke: none;
fill: none;
}
@include mixins.dark-theme {
stroke: none;
fill: none;
}
}
.ProgressCircle .ProgressCircle__fill {
@include mixins.light-theme {
stroke: variables.$color-white;
}
@include mixins.dark-theme {
stroke: variables.$color-white-alpha-90;
}
}
}
.AttachmentStatusIcon__progress-container--incoming {
.ProgressCircle .ProgressCircle__fill {
@include mixins.light-theme {
stroke: variables.$color-gray-90;
}
@include mixins.dark-theme {
stroke: variables.$color-white-alpha-90;
}
background-color: var(--color-label-primary);
}
}

View File

@@ -76,9 +76,8 @@
width: auto;
}
.CallingLobby__CallLinkJoinRequestPendingSpinner {
margin-inline-end: 8px;
color: variables.$color-gray-15;
.CallingLobby__CallLinkJoinRequestPendingText {
margin-inline-start: 8px;
}
.CallingLobby__Footer {

View File

@@ -21,10 +21,7 @@
@include mixins.font-body-2;
}
.DonationProgressModal .SpinnerV2 {
.DonationProgressModal__SpinnerV2 {
display: inline-block;
margin-inline: auto;
}
.DonationProgressModal .SpinnerV2__Path {
color: variables.$color-ultramarine;
}

View File

@@ -67,29 +67,11 @@
&--computing {
cursor: auto;
}
&__SpinnerV2-container {
&__Spinner-container {
@include mixins.position-absolute-center;
}
.ProgressCircle {
@include mixins.position-absolute-center;
.ProgressCircle__background {
stroke: none;
}
}
@include mixins.dark-theme {
.ProgressCircle .ProgressCircle__background {
stroke: none;
}
}
@include mixins.light-theme {
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white;
}
.SpinnerV2 .SpinnerV2__Path {
stroke: variables.$color-white;
}
@include all-audio-icons(variables.$color-gray-90);
@include all-audio-icons(var(--color-label-primary));
&--context-incoming {
&.PlaybackButton--variant-message {
@@ -101,24 +83,11 @@
background: variables.$color-white-alpha-40;
}
}
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-gray-90;
}
.SpinnerV2 .SpinnerV2__Path {
stroke: variables.$color-gray-90;
}
}
}
@include mixins.dark-theme {
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white-alpha-90;
}
.SpinnerV2 .SpinnerV2__Path {
stroke: variables.$color-white-alpha-90;
}
@include all-audio-icons(variables.$color-white-alpha-90);
@include all-audio-icons(var(--color-label-primary));
&--context-incoming {
&.PlaybackButton--variant-message {
@@ -130,12 +99,6 @@
background: variables.$color-white-alpha-40;
}
}
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white-alpha-90;
}
.SpinnerV2 .SpinnerV2__Path {
stroke: variables.$color-white-alpha-90;
}
}
}
@@ -149,12 +112,12 @@
background: variables.$color-white-alpha-40;
}
}
@include all-audio-icons(variables.$color-white);
@include all-audio-icons(var(--color-label-primary-on-color));
}
@include mixins.dark-theme {
&--context-outgoing {
@include all-audio-icons(variables.$color-white-alpha-90);
@include all-audio-icons(var(--color-label-primary-on-color));
}
}
}

View File

@@ -1,28 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
@use '../variables';
.ProgressCircle {
fill: none;
transform: rotate(-90deg);
.ProgressCircle__fill,
.ProgressCircle__background {
fill: none;
}
.ProgressCircle__background {
stroke: variables.$color-gray-20;
@include mixins.dark-theme() {
stroke: variables.$color-gray-60;
}
}
.ProgressCircle__fill {
stroke: variables.$color-ultramarine;
stroke-linecap: round;
transition: stroke-dashoffset 500ms ease-out;
}
}

View File

@@ -1,35 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../variables';
.SpinnerV2 {
animation: SpinnerV2-rotate 2s linear infinite;
}
.SpinnerV2__Path {
stroke: currentColor;
stroke-linecap: round;
animation: SpinnerV2-dash 1.5s ease-in-out infinite;
}
@keyframes SpinnerV2-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes SpinnerV2-dash {
0% {
stroke-dasharray: 2%, 300%;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 180%, 300%;
stroke-dashoffset: -70%;
}
100% {
stroke-dasharray: 180%, 300%;
stroke-dashoffset: -248%;
}
}

View File

@@ -39,10 +39,6 @@
border-radius: 8px;
}
.FunGifPreview__Spinner {
color: light-dark(variables.$color-gray-25, variables.$color-gray-45);
}
.FunGifPreview__ErrorIcon {
width: 36px;
height: 36px;

View File

@@ -49,7 +49,3 @@
}
}
}
.FunResults__Spinner {
color: light-dark(variables.$color-gray-25, variables.$color-gray-45);
}

View File

@@ -155,7 +155,6 @@
@use 'components/ProfileMovedModal.scss';
@use 'components/ProfileNameWarningModal.scss';
@use 'components/ProgressBar.scss';
@use 'components/ProgressCircle.scss';
@use 'components/Quote.scss';
@use 'components/ReactionPickerPicker.scss';
@use 'components/RecordingComposer.scss';
@@ -174,7 +173,6 @@
@use 'components/SignalConnectionsModal.scss';
@use 'components/SignalConversationMuteToggle.scss';
@use 'components/Slider.scss';
@use 'components/SpinnerV2.scss';
@use 'components/StagedLinkPreview.scss';
@use 'components/StickerManager.scss';
@use 'components/Stories.scss';

View File

@@ -365,6 +365,8 @@
@theme {
--animate-*: initial; /* reset defaults */
--animate-fade-out: animate-fade-out 120ms var(--ease-out-cubic);
--animate-spinner-v2-rotate: animate-spinner-v2-rotate 2s linear infinite;
--animate-spinner-v2-dash: animate-spinner-v2-dash 1.5s ease-in-out infinite;
}
@layer base {
@@ -373,4 +375,28 @@
opacity: 0;
}
}
@keyframes animate-spinner-v2-rotate {
0% {
transform: rotate(-180deg);
}
100% {
transform: rotate(180deg);
}
}
@keyframes animate-spinner-v2-dash {
0% {
stroke-dasharray: 2%, 300%;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 180%, 300%;
stroke-dashoffset: -70%;
}
100% {
stroke-dasharray: 180%, 300%;
stroke-dashoffset: -248%;
}
}
}

View File

@@ -5,8 +5,7 @@ import React, { useState } from 'react';
import type { LocalizerType } from '../types/Util';
import { formatFileSize } from '../util/formatFileSize';
import { roundFractionForProgressBar } from '../util/numbers';
import { ProgressCircle } from './ProgressCircle';
import { SpinnerV2 } from './SpinnerV2';
import { ContextMenu } from './ContextMenu';
import { BackupMediaDownloadCancelConfirmationDialog } from './BackupMediaDownloadCancelConfirmationDialog';
import { LeftPaneDialog } from './LeftPaneDialog';
@@ -50,14 +49,10 @@ export function BackupMediaDownloadProgress({
setIsShowingCancelConfirmation(true);
}
const fractionComplete = roundFractionForProgressBar(
downloadedBytes / totalBytes
);
let content: JSX.Element | undefined;
let icon: JSX.Element | undefined;
const isCompleted = fractionComplete === 1;
const isCompleted = downloadedBytes === totalBytes;
const actionButton =
isCompleted || isIdle ? (
@@ -141,8 +136,14 @@ export function BackupMediaDownloadProgress({
);
icon = (
<div className="BackupMediaDownloadProgress__icon">
<ProgressCircle
fractionComplete={fractionComplete}
<SpinnerV2
size={24}
strokeWidth={3}
marginRatio={1}
min={0}
max={totalBytes}
value={downloadedBytes}
variant="brand"
ariaLabel={i18n('icu:BackupMediaDownloadProgress__title-paused')}
/>
</div>
@@ -181,8 +182,14 @@ export function BackupMediaDownloadProgress({
);
icon = (
<div className="BackupMediaDownloadProgress__icon">
<ProgressCircle
fractionComplete={fractionComplete}
<SpinnerV2
size={24}
strokeWidth={3}
marginRatio={1}
min={0}
max={totalBytes}
value={downloadedBytes}
variant="brand"
ariaLabel={i18n('icu:BackupMediaDownloadProgress__title-offline')}
/>
</div>
@@ -204,8 +211,14 @@ export function BackupMediaDownloadProgress({
);
icon = (
<div className="BackupMediaDownloadProgress__icon">
<ProgressCircle
fractionComplete={fractionComplete}
<SpinnerV2
size={24}
strokeWidth={3}
marginRatio={1}
min={0}
max={totalBytes}
value={downloadedBytes}
variant="brand"
ariaLabel={i18n('icu:BackupMediaDownloadProgress__title-in-progress')}
/>
</div>

View File

@@ -303,12 +303,10 @@ export function CallingLobby({
{callMode === CallMode.Adhoc ? (
isAdhocJoinRequestPending ? (
<div className="CallingLobby__CallLinkNotice CallingLobby__CallLinkNotice--join-request-pending">
<SpinnerV2
className="CallingLobby__CallLinkJoinRequestPendingSpinner"
size={16}
strokeWidth={3}
/>
{i18n('icu:CallingLobby__CallLinkNotice--join-request-pending')}
<SpinnerV2 size={16} strokeWidth={1.5} />
<span className="CallingLobby__CallLinkJoinRequestPendingText">
{i18n('icu:CallingLobby__CallLinkNotice--join-request-pending')}
</span>
</div>
) : (
<div className="CallingLobby__CallLinkNotice">

View File

@@ -37,7 +37,9 @@ export function DonationProgressModal(props: PropsType): JSX.Element {
noEscapeClose
noMouseClose
>
<SpinnerV2 size={58} strokeWidth={8} />
<div className="DonationProgressModal__SpinnerV2">
<SpinnerV2 size={58} strokeWidth={4} variant="brand" />
</div>
<div className="DonationProgressModal__text">
{i18n('icu:Donations__Processing')}
</div>

View File

@@ -9,6 +9,7 @@ import type { ButtonProps } from './PlaybackButton';
import { PlaybackButton } from './PlaybackButton';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import { ThemeType } from '../types/Util';
import { AUDIO_MP3 } from '../types/MIME';
export default {
title: 'components/PlaybackButton',
@@ -50,6 +51,16 @@ export function Default(): JSX.Element {
key={`${variant}_${context}_${mod}`}
variant={variant}
label="playback"
attachment={
mod === 'downloading'
? undefined
: {
contentType: AUDIO_MP3,
size: 3000,
totalDownloaded: 1000,
isPermanentlyUndownloadable: false,
}
}
onClick={action('click')}
context={context}
mod={mod}

View File

@@ -5,7 +5,7 @@ import { animated, useSpring } from '@react-spring/web';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { ProgressCircle } from './ProgressCircle';
import type { AttachmentForUIType } from '../types/Attachment';
import { SpinnerV2 } from './SpinnerV2';
const SPRING_CONFIG = {
@@ -19,7 +19,7 @@ export type ButtonProps = {
context?: 'incoming' | 'outgoing';
variant: 'message' | 'mini' | 'draft';
mod: 'play' | 'pause' | 'not-downloaded' | 'downloading' | 'computing';
downloadFraction?: number;
attachment?: AttachmentForUIType;
label: string;
visible?: boolean;
onClick: () => void;
@@ -32,7 +32,7 @@ export const PlaybackButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
function ButtonInner(props, ref) {
const {
context,
downloadFraction,
attachment,
label,
mod,
onClick,
@@ -84,25 +84,24 @@ export const PlaybackButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
let content: JSX.Element | null = null;
const strokeWidth = variant === 'message' ? 2 : 1;
if (mod === 'downloading' && downloadFraction) {
if (mod === 'computing' || mod === 'downloading') {
content = (
<ProgressCircle
fractionComplete={downloadFraction}
width={size}
strokeWidth={strokeWidth}
/>
);
} else if (
mod === 'computing' ||
(mod === 'downloading' && !downloadFraction)
) {
content = (
<div className="PlaybackButton__SpinnerV2-container">
<div className="PlaybackButton__Spinner-container">
<SpinnerV2
className="PlaybackButton__SpinnerV2"
variant={
context === 'incoming'
? 'no-background-incoming'
: 'no-background'
}
min={0}
max={attachment?.size}
value={
attachment?.size && attachment?.totalDownloaded
? attachment.totalDownloaded
: undefined
}
size={size}
strokeWidth={strokeWidth * 2}
marginRatio={1}
strokeWidth={strokeWidth}
/>
</div>
);

View File

@@ -1,49 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { ProgressCircle } from './ProgressCircle';
import type { ComponentMeta } from '../storybook/types';
type Props = React.ComponentProps<typeof ProgressCircle>;
export default {
title: 'Components/ProgressCircle',
component: ProgressCircle,
args: {
fractionComplete: 0,
width: undefined,
strokeWidth: undefined,
ariaLabel: undefined,
},
} satisfies ComponentMeta<Props>;
export function Zero(args: Props): JSX.Element {
return <ProgressCircle {...args} />;
}
export function Thirty(args: Props): JSX.Element {
return <ProgressCircle {...args} fractionComplete={0.3} />;
}
export function Done(args: Props): JSX.Element {
return <ProgressCircle {...args} fractionComplete={1} />;
}
export function Increasing(args: Props): JSX.Element {
const fractionComplete = useIncreasingFractionComplete();
return <ProgressCircle {...args} fractionComplete={fractionComplete} />;
}
function useIncreasingFractionComplete() {
const [fractionComplete, setFractionComplete] = React.useState(0);
React.useEffect(() => {
if (fractionComplete >= 1) {
return;
}
const timeout = setTimeout(() => {
setFractionComplete(cur => Math.min(1, cur + 0.1));
}, 300);
return () => clearTimeout(timeout);
}, [fractionComplete]);
return fractionComplete;
}

View File

@@ -1,54 +0,0 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
export function ProgressCircle({
fractionComplete,
width = 24,
strokeWidth = 3,
ariaLabel,
}: {
fractionComplete: number;
width?: number;
strokeWidth?: number;
ariaLabel?: string;
}): JSX.Element {
const radius = width / 2 - strokeWidth / 2;
const circumference = radius * 2 * Math.PI;
const widthInPixels = `${width}px`;
return (
<svg
className="ProgressCircle"
width={widthInPixels}
height={widthInPixels}
role="progressbar"
aria-label={ariaLabel}
aria-valuenow={Math.trunc(fractionComplete * 100)}
aria-valuemin={0}
aria-valuemax={100}
>
<circle
className="ProgressCircle__background"
strokeWidth={strokeWidth}
r={radius}
cx="50%"
cy="50%"
/>
<circle
className="ProgressCircle__fill"
r={radius}
cx="50%"
cy="50%"
strokeWidth={strokeWidth}
// setting the strokeDashArray to be the circumference of the ring means each dash
// will cover the whole ring
strokeDasharray={circumference}
// offsetting the dash as a fraction of the circumference allows showing the
// progress
strokeDashoffset={(1 - fractionComplete) * circumference}
/>
</svg>
);
}

View File

@@ -1,9 +1,10 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import React, { useEffect, useState } from 'react';
import { SpinnerV2 } from './SpinnerV2';
import { tw } from '../axo/tw';
import type { ComponentMeta } from '../storybook/types';
import type { Props } from './SpinnerV2';
@@ -12,42 +13,85 @@ export default {
title: 'Components/SpinnerV2',
component: SpinnerV2,
argTypes: {
variant: {
options: ['normal', 'no-background', 'no-background-incoming', 'brand'],
control: { type: 'select' },
},
size: { control: { type: 'number' } },
value: { control: { type: 'range', min: 0, max: 1, step: 0.1 } },
strokeWidth: { control: { type: 'number' } },
marginRatio: { control: { type: 'number' } },
},
args: { size: 36, strokeWidth: 2, className: undefined, marginRatio: 0.8 },
args: {
size: 36,
strokeWidth: 2,
marginRatio: 0.8,
min: 0,
max: 1,
value: undefined,
variant: 'normal',
ariaLabel: 'label',
},
} satisfies ComponentMeta<Props>;
export function Default(args: Props): JSX.Element {
return <SpinnerV2 {...args} />;
}
export function Thin(args: Props): JSX.Element {
return <SpinnerV2 {...args} strokeWidth={1} />;
}
export function Thick(args: Props): JSX.Element {
return <SpinnerV2 {...args} strokeWidth={6} />;
}
export function NoMargin(args: Props): JSX.Element {
return <SpinnerV2 {...args} marginRatio={1} strokeWidth={6} />;
}
export function BigMargin(args: Props): JSX.Element {
return <SpinnerV2 {...args} marginRatio={0.5} strokeWidth={6} />;
}
export function Styled(args: Props): JSX.Element {
return (
<div>
<style>{`
.red-spinner {
color: light-dark(hsl(0deg 100% 70%), hsl(0deg 100% 30%));
}
`}</style>
<SpinnerV2 {...args} className="red-spinner" />
<div className={tw('bg-background-overlay')}>
<SpinnerV2 {...args} />
</div>
);
}
export function Thin(args: Props): JSX.Element {
return (
<div className={tw('bg-background-overlay')}>
<SpinnerV2 {...args} strokeWidth={1} />
</div>
);
}
export function Thick(args: Props): JSX.Element {
return (
<div className={tw('bg-background-overlay')}>
<SpinnerV2 {...args} strokeWidth={6} />
</div>
);
}
export function NoMargin(args: Props): JSX.Element {
return (
<div className={tw('bg-background-overlay')}>
<SpinnerV2 {...args} marginRatio={1} strokeWidth={6} />
</div>
);
}
export function BigMargin(args: Props): JSX.Element {
return (
<div className={tw('bg-background-overlay')}>
<SpinnerV2 {...args} marginRatio={0.5} strokeWidth={6} />
</div>
);
}
export function SpinnerToProgress(args: Props): JSX.Element {
const [value, setValue] = useState<number | undefined>();
useEffect(() => {
const timer = setInterval(() => {
setValue(v => {
if (v == null) {
return 0.3;
}
return undefined;
});
}, 2000);
return () => {
clearInterval(timer);
};
});
return (
<div className={tw('bg-background-overlay')}>
<SpinnerV2 {...args} value={value} />
</div>
);
}

View File

@@ -2,39 +2,135 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { tw, type TailwindStyles } from '../axo/tw';
import { roundFractionForProgressBar } from '../util/numbers';
export type Props = {
className?: string;
value?: number | 'indeterminate'; // default: 'indeterminate'
min?: number; // default: 0
max?: number; // default: 1
variant?: SpinnerVariant;
ariaLabel?: string;
marginRatio?: number;
size: number;
strokeWidth: number;
};
type SpinnerVariantStyles = Readonly<{
fg: TailwindStyles;
bg: TailwindStyles;
}>;
const SpinnerVariants = {
normal: {
bg: tw('stroke-label-disabled-on-color'),
fg: tw('stroke-label-primary-on-color'),
},
'no-background': {
bg: tw('stroke-none'),
fg: tw('stroke-label-primary-on-color'),
},
'no-background-incoming': {
bg: tw('stroke-none'),
fg: tw('stroke-label-primary'),
},
brand: {
bg: tw('stroke-fill-secondary'),
fg: tw('stroke-border-selected'),
},
} as const satisfies Record<string, SpinnerVariantStyles>;
export type SpinnerVariant = keyof typeof SpinnerVariants;
export function SpinnerV2({
className,
value = 'indeterminate',
min = 0,
max = 1,
variant = 'normal',
marginRatio,
size,
strokeWidth,
ariaLabel,
}: Props): JSX.Element {
const radius = Math.min(size - strokeWidth / 2, size * (marginRatio ?? 0.8));
const sizeInPixels = `${size}px`;
const radius = Math.min(
size / 2 - strokeWidth / 2,
(size / 2) * (marginRatio ?? 0.8)
);
const circumference = radius * 2 * Math.PI;
const { bg, fg } = SpinnerVariants[variant];
const bgElem = (
<circle
className={tw(bg, 'fill-none')}
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
);
if (value === 'indeterminate') {
return (
<svg
className={tw('fill-none')}
width={sizeInPixels}
height={sizeInPixels}
>
{bgElem}
<g className={tw('origin-center animate-spinner-v2-rotate')}>
<circle
className={tw(fg, 'animate-spinner-v2-dash fill-none')}
cx={size / 2}
cy={size / 2}
r={radius}
style={{
strokeLinecap: 'round',
}}
strokeWidth={strokeWidth}
/>
</g>
</svg>
);
}
const fractionComplete = roundFractionForProgressBar(
(value - min) / (max - min)
);
return (
<svg
className={classNames('SpinnerV2', className)}
viewBox={`0 0 ${size * 2} ${size * 2}`}
style={{
height: size,
width: size,
}}
className={tw('fill-none')}
width={sizeInPixels}
height={sizeInPixels}
role="progressbar"
aria-label={ariaLabel}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
>
<circle
className="SpinnerV2__Path"
cx={size}
cy={size}
r={radius}
fill="none"
strokeWidth={strokeWidth}
/>
{bgElem}
<g className={tw('origin-center -rotate-90')}>
<circle
className={tw(
fg,
'fill-none transition-[stroke-dashoffset] duration-500 ease-out-cubic'
)}
cx={size / 2}
cy={size / 2}
r={radius}
style={{ strokeLinecap: 'round' }}
strokeWidth={strokeWidth}
// setting the strokeDashArray to be the circumference of the ring
// means each dash will cover the whole ring
strokeDasharray={circumference}
// offsetting the dash as a fraction of the circumference allows
// showing the progress
strokeDashoffset={(1 - fractionComplete) * circumference}
/>
</g>
</svg>
);
}

View File

@@ -5,13 +5,11 @@ import React from 'react';
import classNames from 'classnames';
import { formatFileSize } from '../../util/formatFileSize';
import { ProgressCircle } from '../ProgressCircle';
import { SpinnerV2 } from '../SpinnerV2';
import type { AttachmentForUIType } from '../../types/Attachment';
import type { LocalizerType } from '../../types/I18N';
import { Spinner } from '../Spinner';
import { isKeyboardActivation } from '../../hooks/useKeyboardShortcuts';
import { roundFractionForProgressBar } from '../../util/numbers';
export type PropsType = {
attachments: ReadonlyArray<AttachmentForUIType>;
@@ -130,20 +128,21 @@ export function AttachmentDetailPill({
{formatFileSize(totalSize)}
</div>
);
} else if (totalDownloadedSize > 0) {
const downloadFraction = roundFractionForProgressBar(
totalDownloadedSize / totalSize
);
} else {
const isDownloading = totalDownloadedSize > 0;
ariaLabel = i18n('icu:cancelDownload');
onClick = cancelDownloadClick;
onKeyDown = cancelDownloadKeyDown;
control = (
<div className="AttachmentDetailPill__spinner-wrapper">
<ProgressCircle
fractionComplete={downloadFraction}
width={24}
<SpinnerV2
min={0}
max={totalSize}
value={isDownloading ? totalDownloadedSize : 'indeterminate'}
size={24}
strokeWidth={2}
marginRatio={1}
/>
<div className="AttachmentDetailPill__stop-icon" />
</div>
@@ -156,21 +155,6 @@ export function AttachmentDetailPill({
{formatFileSize(totalSize)}
</div>
);
} else {
ariaLabel = i18n('icu:cancelDownload');
onClick = cancelDownloadClick;
onKeyDown = cancelDownloadKeyDown;
control = (
<div className="AttachmentDetailPill__spinner-wrapper">
<Spinner svgSize="small" size="24px" />
<div className="AttachmentDetailPill__stop-icon" />
</div>
);
text = (
<div className="AttachmentDetailPill__text-wrapper">
{formatFileSize(totalSize)}
</div>
);
}
return (

View File

@@ -4,11 +4,10 @@
import React, { useRef, useState } from 'react';
import classNames from 'classnames';
import { ProgressCircle } from '../ProgressCircle';
import { SpinnerV2 } from '../SpinnerV2';
import { usePrevious } from '../../hooks/usePrevious';
import type { AttachmentForUIType } from '../../types/Attachment';
import { roundFractionForProgressBar } from '../../util/numbers';
const TRANSITION_DELAY = 200;
@@ -110,15 +109,12 @@ export function AttachmentStatusIcon({
(state === IconState.Downloaded && isWaiting))
) {
const { size, totalDownloaded } = attachment;
let downloadFraction =
size && totalDownloaded
? roundFractionForProgressBar(totalDownloaded / size)
: undefined;
let spinnerValue = (size && totalDownloaded) || undefined;
if (state === IconState.Downloading && isWaiting) {
downloadFraction = undefined;
spinnerValue = undefined;
}
if (state === IconState.Downloaded && isWaiting) {
downloadFraction = 1;
spinnerValue = size;
}
return (
@@ -131,22 +127,24 @@ export function AttachmentStatusIcon({
: undefined
)}
>
{downloadFraction ? (
<div
className={classNames(
'AttachmentStatusIcon__progress-container',
isIncoming
? 'AttachmentStatusIcon__progress-container--incoming'
: undefined
)}
>
<ProgressCircle
fractionComplete={downloadFraction}
width={36}
strokeWidth={2}
/>
</div>
) : undefined}
<div
className={classNames(
'AttachmentStatusIcon__progress-container',
isIncoming
? 'AttachmentStatusIcon__progress-container--incoming'
: undefined
)}
>
<SpinnerV2
min={0}
max={size}
value={spinnerValue}
variant={isIncoming ? 'no-background-incoming' : 'no-background'}
size={36}
strokeWidth={2}
marginRatio={1}
/>
</div>
<div
className={classNames(
'AttachmentStatusIcon__circle-icon',

View File

@@ -6,7 +6,6 @@ import React, { useCallback } from 'react';
import classNames from 'classnames';
import { ImageOrBlurhash } from '../ImageOrBlurhash';
import { Spinner } from '../Spinner';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { AttachmentForUIType } from '../../types/Attachment';
import {
@@ -14,9 +13,8 @@ import {
isIncremental,
isReadyToView,
} from '../../types/Attachment';
import { ProgressCircle } from '../ProgressCircle';
import { SpinnerV2 } from '../SpinnerV2';
import { useUndownloadableMediaHandler } from '../../hooks/useUndownloadableMediaHandler';
import { roundFractionForProgressBar } from '../../util/numbers';
export enum CurveType {
None = 0,
@@ -334,41 +332,12 @@ export function getSpinner({
i18n: LocalizerType;
tabIndex: number | undefined;
}): JSX.Element | undefined {
const downloadFraction =
attachment.pending &&
!isIncremental(attachment) &&
attachment.size &&
attachment.totalDownloaded
? roundFractionForProgressBar(
attachment.totalDownloaded / attachment.size
)
: undefined;
if (downloadFraction) {
return (
<button
type="button"
className="module-image__overlay-circle"
aria-label={i18n('icu:cancelDownload')}
onClick={cancelDownloadClick}
onKeyDown={cancelDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__stop-icon" />
<div className="module-image__progress-circle-wrapper">
<ProgressCircle
fractionComplete={downloadFraction}
width={44}
strokeWidth={2}
/>
</div>
</button>
);
}
if (!attachment.pending) {
return undefined;
}
const spinnerValue =
(attachment.pending &&
!isIncremental(attachment) &&
attachment.size &&
attachment.totalDownloaded) ||
undefined;
return (
<button
@@ -379,13 +348,16 @@ export function getSpinner({
onKeyDown={cancelDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__spinner-container">
<Spinner
moduleClassName="module-image-spinner"
svgSize="normal"
size="44px"
<div className="module-image__stop-icon" />
<div className="module-image__progress-circle-wrapper">
<SpinnerV2
min={0}
max={attachment.size}
value={spinnerValue}
size={44}
strokeWidth={2}
marginRatio={1}
/>
<div className="module-image__stop-icon" />
</div>
</button>
);

View File

@@ -8,7 +8,7 @@ import { noop } from 'lodash';
import { animated, useSpring } from '@react-spring/web';
import type { LocalizerType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import type { AttachmentForUIType } from '../../types/Attachment';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { isDownloaded } from '../../types/Attachment';
import type { DirectionType, MessageStatusType } from './Message';
@@ -24,7 +24,6 @@ import { useComputePeaks } from '../../hooks/useComputePeaks';
import { durationToPlaybackText } from '../../util/durationToPlaybackText';
import { shouldNeverBeCalled } from '../../util/shouldNeverBeCalled';
import { formatFileSize } from '../../util/formatFileSize';
import { roundFractionForProgressBar } from '../../util/numbers';
const log = createLogger('MessageAudio');
@@ -37,7 +36,7 @@ export type OwnProps = Readonly<{
| undefined;
buttonRef: RefObject<HTMLButtonElement>;
i18n: LocalizerType;
attachment: AttachmentType;
attachment: AttachmentForUIType;
collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
@@ -293,18 +292,11 @@ export function MessageAudio(props: Props): JSX.Element {
/>
);
} else if (state === State.Pending) {
// Not really a button, but who cares?
const downloadFraction =
attachment.size && attachment.totalDownloaded
? roundFractionForProgressBar(
attachment.totalDownloaded / attachment.size
)
: undefined;
button = (
<PlaybackButton
variant="message"
mod="downloading"
downloadFraction={downloadFraction}
attachment={attachment}
onClick={cancelAttachmentDownload}
label={i18n('icu:MessageAudio--pending')}
context={direction}

View File

@@ -154,13 +154,7 @@ export function FunGifPreview(props: FunGifPreviewProps): JSX.Element {
}
/>
<div className="FunGifPreview__Backdrop" role="status">
{spinner && !hasError && (
<SpinnerV2
className="FunGifPreview__Spinner"
size={36}
strokeWidth={4}
/>
)}
{spinner && !hasError && <SpinnerV2 size={36} strokeWidth={2} />}
{hasError && <div className="FunGifPreview__ErrorIcon" />}
</div>
{props.src != null && (

View File

@@ -52,7 +52,5 @@ export function FunResultsButton(props: FunResultsButtonProps): JSX.Element {
}
export function FunResultsSpinner(): JSX.Element {
return (
<SpinnerV2 className="FunResults__Spinner" size={36} strokeWidth={4} />
);
return <SpinnerV2 size={36} strokeWidth={2} />;
}