diff --git a/src/components/Loading/CircularProgressIndicator.tsx b/src/components/Loading/CircularProgressIndicator.tsx new file mode 100644 index 000000000..511d7d2fb --- /dev/null +++ b/src/components/Loading/CircularProgressIndicator.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { useTranslationContext } from '../../context/TranslationContext'; + +const RING_RADIUS = 12; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +export type CircularProgressIndicatorProps = { + /** Clamped 0–100 completion. */ + percent: number; +}; + +/** Circular progress indicator with input from 0 to 100. */ +export const CircularProgressIndicator = ({ + percent, +}: CircularProgressIndicatorProps) => { + const { t } = useTranslationContext('CircularProgressIndicator'); + const dashOffset = RING_CIRCUMFERENCE * (1 - percent / 100); + + return ( +
+ + + + +
+ ); +}; diff --git a/src/components/Loading/index.ts b/src/components/Loading/index.ts index 75b5c332f..8bf0565af 100644 --- a/src/components/Loading/index.ts +++ b/src/components/Loading/index.ts @@ -2,3 +2,4 @@ export * from './LoadingChannel'; export * from './LoadingChannels'; export * from './LoadingErrorIndicator'; export * from './LoadingIndicator'; +export * from './CircularProgressIndicator'; diff --git a/src/components/Loading/styling/CircularProgressIndicator.scss b/src/components/Loading/styling/CircularProgressIndicator.scss new file mode 100644 index 000000000..5cb6f1a28 --- /dev/null +++ b/src/components/Loading/styling/CircularProgressIndicator.scss @@ -0,0 +1,8 @@ +.str-chat__circular-progress-indicator { + width: 100%; + height: 100%; + + svg { + display: block; + } +} diff --git a/src/components/Loading/styling/index.scss b/src/components/Loading/styling/index.scss index 1a37d4328..31084a7e0 100644 --- a/src/components/Loading/styling/index.scss +++ b/src/components/Loading/styling/index.scss @@ -1,2 +1,3 @@ @use 'LoadingChannels'; @use 'LoadingIndicator'; +@use 'CircularProgressIndicator'; diff --git a/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadProgressIndicator.tsx b/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadProgressIndicator.tsx new file mode 100644 index 000000000..5e436174a --- /dev/null +++ b/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadProgressIndicator.tsx @@ -0,0 +1,32 @@ +import clsx from 'clsx'; +import React, { type ReactNode } from 'react'; + +import { CircularProgressIndicator as DefaultCircularProgressIndicator } from '../../Loading'; +import { useComponentContext } from '../../../context'; +import { LoadingIndicatorIcon } from '../icons'; + +export type AttachmentUploadProgressIndicatorProps = { + className?: string; + /** Shown when `uploadProgress` is `undefined` (e.g. progress tracking disabled). */ + fallback?: ReactNode; + uploadProgress?: number; +}; + +export const AttachmentUploadProgressIndicator = ({ + className, + fallback, + uploadProgress, +}: AttachmentUploadProgressIndicatorProps) => { + const { CircularProgressIndicator = DefaultCircularProgressIndicator } = + useComponentContext(); + + if (uploadProgress === undefined) { + return <>{fallback ?? }; + } + + return ( +
+ +
+ ); +}; diff --git a/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadedSizeIndicator.tsx b/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadedSizeIndicator.tsx new file mode 100644 index 000000000..b0e2b8e98 --- /dev/null +++ b/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadedSizeIndicator.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { FileSizeIndicator } from '../../Attachment'; +import { prettifyFileSize } from '../hooks/utils'; + +function safePrettifyFileSize(bytes: number, maximumFractionDigits?: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return ''; + if (bytes === 0) return '0 B'; + return prettifyFileSize(bytes, maximumFractionDigits); +} + +function formatUploadByteFraction( + uploadPercent: number, + fullBytes: number, + maximumFractionDigits?: number, +): string { + const uploaded = Math.round((uploadPercent / 100) * fullBytes); + return `${safePrettifyFileSize(uploaded, maximumFractionDigits)} / ${safePrettifyFileSize(fullBytes, maximumFractionDigits)}`; +} + +function resolveAttachmentFullByteSize(attachment: { + file_size?: number | string; + localMetadata?: { file?: { size?: unknown } } | null; +}): number | undefined { + const fromFile = attachment.localMetadata?.file?.size; + if (typeof fromFile === 'number' && Number.isFinite(fromFile) && fromFile >= 0) { + return fromFile; + } + const raw = attachment.file_size; + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; + if (typeof raw === 'string') { + const n = parseFloat(raw); + if (Number.isFinite(n) && n >= 0) return n; + } + return undefined; +} + +export type AttachmentUploadedSizeIndicatorProps = { + attachment: { + file_size?: number | string; + localMetadata?: { + file?: { size?: unknown }; + uploadProgress?: number; + uploadState?: string; + } | null; + }; +}; + +export const AttachmentUploadedSizeIndicator = ({ + attachment, +}: AttachmentUploadedSizeIndicatorProps) => { + const { uploadProgress, uploadState } = attachment.localMetadata ?? {}; + const fullBytes = resolveAttachmentFullByteSize(attachment); + + if ( + uploadState === 'uploading' && + uploadProgress !== undefined && + fullBytes !== undefined + ) { + return ( + + {formatUploadByteFraction(uploadProgress, fullBytes)} + + ); + } + + if (uploadState === 'finished') { + return ; + } + + return null; +}; diff --git a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx index 4c2d94035..101d6fe45 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -7,10 +7,9 @@ import { import { useTranslationContext } from '../../../context'; import React, { useEffect } from 'react'; import clsx from 'clsx'; -import { LoadingIndicatorIcon } from '../icons'; +import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; -import { FileSizeIndicator } from '../../Attachment'; import { IconExclamationMark, IconExclamationTriangleFill } from '../../Icons'; import { PlayButton } from '../../Button'; import { @@ -21,6 +20,7 @@ import { } from '../../AudioPlayback'; import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../../store'; +import { AttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator'; export type AudioAttachmentPreviewProps> = UploadAttachmentPreviewProps< @@ -42,7 +42,7 @@ export const AudioAttachmentPreview = ({ removeAttachments, }: AudioAttachmentPreviewProps) => { const { t } = useTranslationContext(); - const { id, previewUri, uploadPermissionCheck, uploadState } = + const { id, previewUri, uploadPermissionCheck, uploadProgress, uploadState } = attachment.localMetadata ?? {}; const url = attachment.asset_url || previewUri; @@ -93,11 +93,13 @@ export const AudioAttachmentPreview = ({ {isVoiceRecordingAttachment(attachment) ? t('Voice message') : attachment.title}
- {uploadState === 'uploading' && } + {uploadState === 'uploading' && ( + + )} {showProgressControls ? ( <> {!resolvedDuration && !progressPercent && !isPlaying && ( - + )} {hasWaveform ? ( <> diff --git a/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx index 01103c593..08a6da259 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -1,13 +1,12 @@ import React from 'react'; import { useTranslationContext } from '../../../context'; import { FileIcon } from '../../FileIcon'; -import { LoadingIndicatorIcon } from '../icons'; - +import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; +import { AttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator'; import type { LocalAudioAttachment, LocalFileAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; -import { FileSizeIndicator } from '../../Attachment'; import { IconExclamationMark, IconExclamationTriangleFill } from '../../Icons'; export type FileAttachmentPreviewProps = @@ -21,12 +20,12 @@ export const FileAttachmentPreview = ({ removeAttachments, }: FileAttachmentPreviewProps) => { const { t } = useTranslationContext('FilePreview'); - const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {}; + const { id, uploadPermissionCheck, uploadProgress, uploadState } = + attachment.localMetadata ?? {}; const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; const hasRetriableError = uploadState === 'failed' && !!handleRetry; - const hasError = hasRetriableError || hasFatalError; return (
- {uploadState === 'uploading' && } - {!hasError && } + {uploadState === 'uploading' && ( + + )} + {hasFatalError && (
diff --git a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx index 8336ab399..24e72dbc9 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -17,6 +17,7 @@ import clsx from 'clsx'; import { IconExclamationMark, IconRetry, IconVideoFill } from '../../Icons'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { Button } from '../../Button'; +import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; @@ -38,7 +39,8 @@ export const MediaAttachmentPreview = ({ useComponentContext(); const [thumbnailPreviewError, setThumbnailPreviewError] = useState(false); - const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {}; + const { id, uploadPermissionCheck, uploadProgress, uploadState } = + attachment.localMetadata ?? {}; const isUploading = uploadState === 'uploading'; const handleThumbnailLoadError = useCallback(() => setThumbnailPreviewError(true), []); @@ -94,7 +96,12 @@ export const MediaAttachmentPreview = ({ )}
- {isUploading && } + {isUploading && ( + } + uploadProgress={uploadProgress} + /> + )} {isVideoAttachment(attachment) && !hasUploadError && diff --git a/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx b/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx index 88bde9223..0acd77d47 100644 --- a/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx +++ b/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx @@ -341,6 +341,57 @@ describe('AttachmentPreviewList', () => { }, ); + describe('upload progress UI', () => { + it('shows spinner while uploading when uploadProgress is omitted', async () => { + await renderComponent({ + attachments: [ + { + ...generateFileAttachment({ title: 'f.pdf' }), + localMetadata: { id: 'a1', uploadState: 'uploading' }, + }, + ], + }); + + expect(screen.getByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId('circular-progress-ring')).not.toBeInTheDocument(); + }); + + it('shows ring while uploading when uploadProgress is numeric', async () => { + await renderComponent({ + attachments: [ + { + ...generateImageAttachment({ fallback: 'img.png' }), + localMetadata: { + id: 'a1', + uploadProgress: 42, + uploadState: 'uploading', + }, + }, + ], + }); + + expect(screen.getByTestId('circular-progress-ring')).toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).not.toBeInTheDocument(); + }); + + it('shows uploaded size fraction for file attachments when progress is tracked', async () => { + await renderComponent({ + attachments: [ + { + ...generateFileAttachment({ file_size: 1000, title: 'sized.pdf' }), + localMetadata: { + id: 'a1', + uploadProgress: 50, + uploadState: 'uploading', + }, + }, + ], + }); + + expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(/\s*\/\s*/); + }); + }); + it('should render custom BaseImage component', async () => { const BaseImage = (props) => ; const { container } = await renderComponent({ diff --git a/src/components/MessageComposer/__tests__/AttachmentUploadedSizeIndicator.test.tsx b/src/components/MessageComposer/__tests__/AttachmentUploadedSizeIndicator.test.tsx new file mode 100644 index 000000000..59ab50731 --- /dev/null +++ b/src/components/MessageComposer/__tests__/AttachmentUploadedSizeIndicator.test.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AttachmentUploadedSizeIndicator } from '../AttachmentPreviewList/AttachmentUploadedSizeIndicator'; + +describe('AttachmentUploadedSizeIndicator', () => { + it('renders nothing when upload state is not uploading or finished', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when uploading without uploadProgress', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when uploading without a resolvable full byte size', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders upload size fraction when uploading with numeric file_size and progress', () => { + render( + , + ); + + expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent( + '500 B / 1.00e+3 B', + ); + expect(screen.getByTestId('upload-size-fraction')).toHaveClass( + 'str-chat__attachment-preview-file__upload-size-fraction', + ); + }); + + it('parses string file_size for the upload fraction', () => { + render( + , + ); + + expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent( + '500 B / 1.00e+3 B', + ); + }); + + it('prefers localMetadata.file.size over file_size when both are present', () => { + render( + , + ); + + expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent('100 B / 200 B'); + }); + + it('renders FileSizeIndicator when upload is finished', () => { + render( + , + ); + + expect(screen.getByTestId('file-size-indicator')).toHaveTextContent('1.00 kB'); + }); + + it('renders nothing when finished but file_size is missing or invalid', () => { + const { container: missing } = render( + , + ); + expect(missing.firstChild).toBeNull(); + + const { container: nanString } = render( + , + ); + expect(nanString.firstChild).toBeNull(); + }); +}); diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 320ba6e85..7a6dd0444 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -345,6 +345,16 @@ const setupUploadRejected = async (error: unknown) => { return { customChannel, customClient, sendFileSpy, sendImageSpy }; }; +/** `channel.sendImage` / `channel.sendFile` pass upload options (e.g. `onUploadProgress`) after the file. */ +const expectChannelUploadCall = (spy, expectedFile) => { + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]; + expect(callArgs[0]).toBe(expectedFile); + expect(callArgs[callArgs.length - 1]).toEqual( + expect.objectContaining({ onUploadProgress: expect.any(Function) }), + ); +}; + const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => { const { channels: [channel], @@ -562,8 +572,8 @@ describe(`MessageInputFlat`, () => { }); const filenameTexts = await screen.findAllByTitle(filename); await waitFor(() => { - expect(sendFileSpy).toHaveBeenCalledWith(file); - expect(sendImageSpy).toHaveBeenCalledWith(image); + expectChannelUploadCall(sendFileSpy, file); + expectChannelUploadCall(sendImageSpy, image); expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument(); expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument(); filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument()); @@ -634,7 +644,7 @@ describe(`MessageInputFlat`, () => { dropFile(file, formElement); }); await waitFor(() => { - expect(sendImageSpy).toHaveBeenCalledWith(file); + expectChannelUploadCall(sendImageSpy, file); }); const results = await axe(container); expect(results).toHaveNoViolations(); diff --git a/src/components/MessageComposer/styling/AttachmentPreview.scss b/src/components/MessageComposer/styling/AttachmentPreview.scss index 5ca9af316..efa461e75 100644 --- a/src/components/MessageComposer/styling/AttachmentPreview.scss +++ b/src/components/MessageComposer/styling/AttachmentPreview.scss @@ -210,14 +210,14 @@ ); } - .str-chat__icon.str-chat__loading-indicator { + .str-chat__loading-indicator, + .str-chat__attachment-upload-progress { width: var(--icon-size-sm); height: var(--icon-size-sm); position: absolute; inset-inline-start: var(--spacing-xxs); bottom: var(--spacing-xxs); border-radius: var(--radius-max); - border-radius: var(--radius-max); background: var(--background-core-elevation-0); color: var(--accent-primary); } @@ -283,9 +283,15 @@ font-size: var(--typography-font-size-xs); line-height: var(--typography-line-height-tight); - .str-chat__icon.str-chat__loading-indicator { + .str-chat__loading-indicator, + .str-chat__attachment-upload-progress { width: var(--icon-size-sm); height: var(--icon-size-sm); + color: var(--accent-primary); + } + + .str-chat__attachment-preview-file__upload-size-fraction { + white-space: nowrap; } .str-chat__attachment-preview-file__fatal-error { diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index d2466c012..d1696fcc2 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -74,6 +74,7 @@ import type { VideoPlayerProps } from '../components/VideoPlayer'; import type { EditedMessagePreviewProps } from '../components/MessageComposer/EditedMessagePreview'; import type { FileIconProps } from '../components/FileIcon/FileIcon'; import type { CommandChipProps } from '../components/MessageComposer/CommandChip'; +import type { CircularProgressIndicatorProps } from '../components/Loading/CircularProgressIndicator'; export type ComponentContextValue = { /** Custom UI component to display additional message composer action buttons left to the textarea, defaults to and accepts same props as: [AdditionalMessageComposerActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/MessageComposerActions.tsx) */ @@ -148,6 +149,8 @@ export type ComponentContextValue = { LoadingErrorIndicator?: React.ComponentType; /** Custom UI component to render while the `MessageList` is loading new messages, defaults to and accepts same props as: [LoadingIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingIndicator.tsx) */ LoadingIndicator?: React.ComponentType; + /** Custom UI component for determinate progress (0–100), defaults to and accepts same props as: [CircularProgressIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/CircularProgressIndicator.tsx) */ + CircularProgressIndicator?: React.ComponentType; /** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageUI.tsx) */ Message?: React.ComponentType; /** Custom UI component for message actions popup, accepts no props, all the defaults are set within [MessageActions (unstable)](https://github.com/GetStream/stream-chat-react/blob/master/src/experimental/MessageActions/MessageActions.tsx) */ diff --git a/src/i18n/de.json b/src/i18n/de.json index b43c4eb31..c9ac9f639 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -83,6 +83,7 @@ "aria/Open Message Actions Menu": "Nachrichtenaktionsmenü öffnen", "aria/Open Reaction Selector": "Reaktionsauswahl öffnen", "aria/Open Thread": "Thread öffnen", + "aria/Percent complete": "{{percent}} Prozent abgeschlossen", "aria/Pin Message": "Nachricht anheften", "aria/Quote Message": "Nachricht zitieren", "aria/Reaction list": "Reaktionsliste", diff --git a/src/i18n/en.json b/src/i18n/en.json index 0cea529a0..e0a1f27e8 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -83,6 +83,7 @@ "aria/Open Message Actions Menu": "Open Message Actions Menu", "aria/Open Reaction Selector": "Open Reaction Selector", "aria/Open Thread": "Open Thread", + "aria/Percent complete": "{{percent}} percent complete", "aria/Pin Message": "Pin Message", "aria/Quote Message": "Quote Message", "aria/Reaction list": "Reaction list", diff --git a/src/i18n/es.json b/src/i18n/es.json index 88104736d..e5e5d4e38 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -91,6 +91,7 @@ "aria/Open Message Actions Menu": "Abrir menú de acciones de mensaje", "aria/Open Reaction Selector": "Abrir selector de reacciones", "aria/Open Thread": "Abrir hilo", + "aria/Percent complete": "{{percent}} por ciento completado", "aria/Pin Message": "Fijar mensaje", "aria/Quote Message": "Citar mensaje", "aria/Reaction list": "Lista de reacciones", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index f63942d54..3c54f13c1 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -91,6 +91,7 @@ "aria/Open Message Actions Menu": "Ouvrir le menu des actions du message", "aria/Open Reaction Selector": "Ouvrir le sélecteur de réactions", "aria/Open Thread": "Ouvrir le fil", + "aria/Percent complete": "{{percent}} pour cent terminé", "aria/Pin Message": "Épingler le message", "aria/Quote Message": "Citer le message", "aria/Reaction list": "Liste des réactions", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index ad2da9d82..aec01fb3d 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -83,6 +83,7 @@ "aria/Open Message Actions Menu": "संदेश क्रिया मेन्यू खोलें", "aria/Open Reaction Selector": "प्रतिक्रिया चयनकर्ता खोलें", "aria/Open Thread": "थ्रेड खोलें", + "aria/Percent complete": "{{percent}} प्रतिशत पूर्ण", "aria/Pin Message": "संदेश पिन करें", "aria/Quote Message": "संदेश उद्धरण", "aria/Reaction list": "प्रतिक्रिया सूची", diff --git a/src/i18n/it.json b/src/i18n/it.json index 9204470ce..a9d840731 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -91,6 +91,7 @@ "aria/Open Message Actions Menu": "Apri il menu delle azioni di messaggio", "aria/Open Reaction Selector": "Apri il selettore di reazione", "aria/Open Thread": "Apri discussione", + "aria/Percent complete": "{{percent}} percento completato", "aria/Pin Message": "Appunta messaggio", "aria/Quote Message": "Citazione messaggio", "aria/Reaction list": "Elenco delle reazioni", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 063b2c0cc..525204ee7 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -82,6 +82,7 @@ "aria/Open Message Actions Menu": "メッセージアクションメニューを開く", "aria/Open Reaction Selector": "リアクションセレクターを開く", "aria/Open Thread": "スレッドを開く", + "aria/Percent complete": "{{percent}}パーセント完了", "aria/Pin Message": "メッセージをピン", "aria/Quote Message": "メッセージを引用", "aria/Reaction list": "リアクション一覧", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 069d865b8..e9d3e6357 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -82,6 +82,7 @@ "aria/Open Message Actions Menu": "메시지 액션 메뉴 열기", "aria/Open Reaction Selector": "반응 선택기 열기", "aria/Open Thread": "스레드 열기", + "aria/Percent complete": "{{percent}}퍼센트 완료", "aria/Pin Message": "메시지 고정", "aria/Quote Message": "메시지 인용", "aria/Reaction list": "반응 목록", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 1f64ceedf..77bd5dc26 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -83,6 +83,7 @@ "aria/Open Message Actions Menu": "Menu voor berichtacties openen", "aria/Open Reaction Selector": "Reactiekiezer openen", "aria/Open Thread": "Draad openen", + "aria/Percent complete": "{{percent}} procent voltooid", "aria/Pin Message": "Bericht vastmaken", "aria/Quote Message": "Bericht citeren", "aria/Reaction list": "Reactielijst", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 0d01d0609..663bca08a 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -91,6 +91,7 @@ "aria/Open Message Actions Menu": "Abrir menu de ações de mensagem", "aria/Open Reaction Selector": "Abrir seletor de reações", "aria/Open Thread": "Abrir tópico", + "aria/Percent complete": "{{percent}} por cento concluído", "aria/Pin Message": "Fixar mensagem", "aria/Quote Message": "Citar mensagem", "aria/Reaction list": "Lista de reações", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 068ae9070..aa0b92f29 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -100,6 +100,7 @@ "aria/Open Message Actions Menu": "Открыть меню действий с сообщениями", "aria/Open Reaction Selector": "Открыть селектор реакций", "aria/Open Thread": "Открыть тему", + "aria/Percent complete": "{{percent}} процентов завершено", "aria/Pin Message": "Закрепить сообщение", "aria/Quote Message": "Цитировать сообщение", "aria/Reaction list": "Список реакций", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 0576ebd10..c792c01d3 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -83,6 +83,7 @@ "aria/Open Message Actions Menu": "Mesaj İşlemleri Menüsünü Aç", "aria/Open Reaction Selector": "Tepki Seçiciyi Aç", "aria/Open Thread": "Konuyu Aç", + "aria/Percent complete": "Yüzde {{percent}} tamamlandı", "aria/Pin Message": "Mesajı sabitle", "aria/Quote Message": "Mesajı alıntıla", "aria/Reaction list": "Tepki listesi",