From eaa529528cafcfb291009f9f99d296c81e792899 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 24 May 2022 16:29:01 +0200 Subject: Support ICU in TS components --- .../account-video-channels.component.html | 4 +- client/src/app/+accounts/accounts.component.html | 4 +- client/src/app/+accounts/accounts.component.ts | 10 -- .../edit-configuration.service.ts | 8 +- .../following-list/follow-modal.component.ts | 9 +- .../comments/video-comment-list.component.ts | 9 +- .../users/user-list/user-list.component.ts | 47 +++++++-- .../+admin/overview/videos/video-list.component.ts | 41 ++++++-- ...y-account-notification-preferences.component.ts | 2 +- .../+my-library/my-history/my-history.component.ts | 10 +- .../+my-library/my-videos/my-videos.component.ts | 15 ++- client/src/app/+search/search.component.ts | 8 +- .../+video-channels/video-channels.component.html | 4 +- .../videos-list-common-page.component.ts | 25 ++++- client/src/app/core/rest/rest-extractor.service.ts | 111 ++++++++++++--------- client/src/app/helpers/i18n-utils.ts | 25 +++++ client/src/app/helpers/utils/upload.ts | 49 +++++---- .../select/select-checkbox-all.component.ts | 8 +- .../instance-features-table.component.ts | 18 ++-- .../shared/shared-main/angular/from-now.pipe.ts | 34 +++++-- .../app/shared/shared-main/video/video.model.ts | 11 +- .../shared-moderation/user-ban-modal.component.ts | 21 +++- .../user-moderation-dropdown.component.ts | 3 +- .../shared-moderation/video-block.component.ts | 8 +- .../user-video-settings.component.html | 2 +- .../video-miniature.component.ts | 2 +- 26 files changed, 333 insertions(+), 155 deletions(-) (limited to 'client/src/app') diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html index 379c0443e..34dc52029 100644 --- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html +++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html @@ -23,10 +23,10 @@
-
{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
+
{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
- {getTotalVideosOf(videoChannel), plural, =1 {1 videos} other {{{ getTotalVideosOf(videoChannel) }} videos}} + {getTotalVideosOf(videoChannel), plural, =0 {No videos} =1 {1 video} other {{{ getTotalVideosOf(videoChannel) }} videos}}
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index 8362e6b7e..e235d9689 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html @@ -33,10 +33,10 @@
- {naiveAggregatedSubscribers(), plural, =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}} + {naiveAggregatedSubscribers(), plural, =0 {No subscribers} =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}} - {accountVideosCount, plural, =1 {1 videos} other {{{ accountVideosCount }} videos}} + {accountVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ accountVideosCount }} videos}}
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index 898325492..cf66b817a 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts @@ -30,8 +30,6 @@ export class AccountsComponent implements OnInit, OnDestroy { links: ListOverflowItem[] = [] hideMenu = false - accountFollowerTitle = '' - accountVideosCount: number accountDescriptionHTML = '' accountDescriptionExpanded = false @@ -121,12 +119,6 @@ export class AccountsComponent implements OnInit, OnDestroy { this.notifier.success($localize`Username copied`) } - subscribersDisplayFor (count: number) { - if (count === 1) return $localize`1 subscriber` - - return $localize`${count} subscribers` - } - searchChanged (search: string) { const queryParams = { search } @@ -150,8 +142,6 @@ export class AccountsComponent implements OnInit, OnDestroy { } private async onAccount (account: Account) { - this.accountFollowerTitle = $localize`${account.followersCount} direct account followers` - this.accountDescriptionHTML = await this.markdown.textMarkdownToHTML(account.description) // After the markdown renderer to avoid layout changes diff --git a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts b/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts index 9b55cb43c..96f5b830e 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core' import { FormGroup } from '@angular/forms' +import { prepareIcu } from '@app/helpers' export type ResolutionOption = { id: string @@ -86,9 +87,10 @@ export class EditConfigurationService { return { value, atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible - unit: value > 1 - ? $localize`threads` - : $localize`thread` + unit: prepareIcu($localize`{value, plural, =1 {thread} other {threads}}`)( + { value }, + $localize`threads` + ) } } } diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts index c40b36e10..bac7b2b01 100644 --- a/client/src/app/+admin/follows/following-list/follow-modal.component.ts +++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts @@ -1,5 +1,6 @@ import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Notifier } from '@app/core' +import { prepareIcu } from '@app/helpers' import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { InstanceFollowService } from '@app/shared/shared-instance' @@ -60,7 +61,13 @@ export class FollowModalComponent extends FormReactive implements OnInit { this.followService.follow(hostsOrHandles) .subscribe({ next: () => { - this.notifier.success($localize`Follow request(s) sent!`) + this.notifier.success( + prepareIcu($localize`{count, plural, =1 {Follow request} other {Follow requests}} sent!`)( + { count: hostsOrHandles.length }, + $localize`Follow request(s) sent!` + ) + ) + this.newFollow.emit() }, diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts index f3f43a900..f1b27d846 100644 --- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts +++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts @@ -7,6 +7,7 @@ import { DropdownAction } from '@app/shared/shared-main' import { BulkService } from '@app/shared/shared-moderation' import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment' import { FeedFormat, UserRight } from '@shared/models' +import { prepareIcu } from '@app/helpers' @Component({ selector: 'my-video-comment-list', @@ -145,7 +146,13 @@ export class VideoCommentListComponent extends RestTable implements OnInit { this.videoCommentService.deleteVideoComments(commentArgs) .subscribe({ next: () => { - this.notifier.success($localize`${commentArgs.length} comments deleted.`) + this.notifier.success( + prepareIcu($localize`{count, plural, =1 {1 comment} other {{count} comments}} deleted.`)( + { count: commentArgs.length }, + $localize`${commentArgs.length} comment(s) deleted.` + ) + ) + this.reloadData() }, diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts index 9d11bd02e..f7dc22256 100644 --- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts +++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts @@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api' import { Component, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' -import { getAPIHost } from '@app/helpers' +import { prepareIcu, getAPIHost } from '@app/helpers' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { Actor, DropdownAction } from '@app/shared/shared-main' import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation' @@ -209,13 +209,25 @@ export class UserListComponent extends RestTable implements OnInit { } async unbanUsers (users: User[]) { - const res = await this.confirmService.confirm($localize`Do you really want to unban ${users.length} users?`, $localize`Unban`) + const res = await this.confirmService.confirm( + prepareIcu($localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`)( + { count: users.length }, + $localize`Do you really want to unban ${users.length} users?` + ), + $localize`Unban` + ) + if (res === false) return this.userAdminService.unbanUsers(users) .subscribe({ next: () => { - this.notifier.success($localize`${users.length} users unbanned.`) + this.notifier.success( + prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} unbanned.`)( + { count: users.length }, + $localize`${users.length} users unbanned.` + ) + ) this.reloadData() }, @@ -224,21 +236,28 @@ export class UserListComponent extends RestTable implements OnInit { } async removeUsers (users: User[]) { - for (const user of users) { - if (user.username === 'root') { - this.notifier.error($localize`You cannot delete root.`) - return - } + if (users.some(u => u.username === 'root')) { + this.notifier.error($localize`You cannot delete root.`) + return } - const message = $localize`If you remove these users, you will not be able to create others with the same username!` + const message = $localize`

You can't create users or channels with a username that already used by a deleted user/channel.

` + + $localize`It means the following usernames will be permanently deleted and cannot be recovered:` + + '' + const res = await this.confirmService.confirm(message, $localize`Delete`) if (res === false) return this.userAdminService.removeUser(users) .subscribe({ next: () => { - this.notifier.success($localize`${users.length} users deleted.`) + this.notifier.success( + prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} deleted.`)( + { count: users.length }, + $localize`${users.length} users deleted.` + ) + ) + this.reloadData() }, @@ -250,7 +269,13 @@ export class UserListComponent extends RestTable implements OnInit { this.userAdminService.updateUsers(users, { emailVerified: true }) .subscribe({ next: () => { - this.notifier.success($localize`${users.length} users email set as verified.`) + this.notifier.success( + prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} email set as verified.`)( + { count: users.length }, + $localize`${users.length} users email set as verified.` + ) + ) + this.reloadData() }, diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts index 82ff372aa..67e52d100 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.ts +++ b/client/src/app/+admin/overview/videos/video-list.component.ts @@ -3,6 +3,7 @@ import { finalize } from 'rxjs/operators' import { Component, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' +import { prepareIcu } from '@app/helpers' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation' @@ -196,14 +197,24 @@ export class VideoListComponent extends RestTable implements OnInit { } private async removeVideos (videos: Video[]) { - const message = $localize`Are you sure you want to delete these ${videos.length} videos?` + const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)( + { count: videos.length }, + $localize`Are you sure you want to delete these ${videos.length} videos?` + ) + const res = await this.confirmService.confirm(message, $localize`Delete`) if (res === false) return this.videoService.removeVideo(videos.map(v => v.id)) .subscribe({ next: () => { - this.notifier.success($localize`Deleted ${videos.length} videos.`) + this.notifier.success( + prepareIcu($localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`)( + { count: videos.length }, + $localize`Deleted ${videos.length} videos.` + ) + ) + this.reloadData() }, @@ -215,7 +226,13 @@ export class VideoListComponent extends RestTable implements OnInit { this.videoBlockService.unblockVideo(videos.map(v => v.id)) .subscribe({ next: () => { - this.notifier.success($localize`Unblocked ${videos.length} videos.`) + this.notifier.success( + prepareIcu($localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`)( + { count: videos.length }, + $localize`Unblocked ${videos.length} videos.` + ) + ) + this.reloadData() }, @@ -224,9 +241,21 @@ export class VideoListComponent extends RestTable implements OnInit { } private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') { - const message = type === 'hls' - ? $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?` - : $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?` + let message: string + + if (type === 'hls') { + // eslint-disable-next-line max-len + message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`)( + { count: videos.length }, + $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?` + ) + } else { + // eslint-disable-next-line max-len + message = prepareIcu($localize`Are you sure you want to delete WebTorrent files of {count, plural, =1 {1 video} other {{count} videos}}?`)( + { count: videos.length }, + $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?` + ) + } const res = await this.confirmService.confirm(message, $localize`Delete`) if (res === false) return diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts index 7c13282fa..769ab647a 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts @@ -37,7 +37,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit { myVideoPublished: $localize`Video published (after transcoding/scheduled update)`, myVideoImportFinished: $localize`Video import finished`, newUserRegistration: $localize`A new user registered on your instance`, - newFollow: $localize`You or your channel(s) has a new follower`, + newFollow: $localize`You or one of your channels has a new follower`, commentMention: $localize`Someone mentioned you in video comments`, newInstanceFollower: $localize`Your instance has a new follower`, autoInstanceFollowing: $localize`Your instance automatically followed another instance`, diff --git a/client/src/app/+my-library/my-history/my-history.component.ts b/client/src/app/+my-library/my-history/my-history.component.ts index f6b712908..766869637 100644 --- a/client/src/app/+my-library/my-history/my-history.component.ts +++ b/client/src/app/+my-library/my-history/my-history.component.ts @@ -93,8 +93,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook { .subscribe({ next: () => { const message = this.videosHistoryEnabled === true - ? $localize`Videos history is enabled` - : $localize`Videos history is disabled` + ? $localize`Video history is enabled` + : $localize`Video history is disabled` this.notifier.success(message) @@ -117,8 +117,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook { } async clearAllHistory () { - const title = $localize`Delete videos history` - const message = $localize`Are you sure you want to delete all your videos history?` + const title = $localize`Delete video history` + const message = $localize`Are you sure you want to delete all your video history?` const res = await this.confirmService.confirm(message, title) if (res !== true) return @@ -126,7 +126,7 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook { this.userHistoryService.clearAll() .subscribe({ next: () => { - this.notifier.success($localize`Videos history deleted`) + this.notifier.success($localize`Video history deleted`) this.reloadData() }, diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts index 64e56a250..c8012ec78 100644 --- a/client/src/app/+my-library/my-videos/my-videos.component.ts +++ b/client/src/app/+my-library/my-videos/my-videos.component.ts @@ -4,7 +4,7 @@ import { Component, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core' import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' -import { immutableAssign } from '@app/helpers' +import { prepareIcu, immutableAssign } from '@app/helpers' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' @@ -167,7 +167,10 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { .map(k => parseInt(k, 10)) const res = await this.confirmService.confirm( - $localize`Do you really want to delete ${toDeleteVideosIds.length} videos?`, + prepareIcu($localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`)( + { length: toDeleteVideosIds.length }, + $localize`Do you really want to delete ${toDeleteVideosIds.length} videos?` + ), $localize`Delete` ) if (res === false) return @@ -184,7 +187,13 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { .pipe(toArray()) .subscribe({ next: () => { - this.notifier.success($localize`${toDeleteVideosIds.length} videos deleted.`) + this.notifier.success( + prepareIcu($localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`)( + { length: toDeleteVideosIds.length }, + $localize`${toDeleteVideosIds.length} have been deleted.` + ) + ) + this.selection = {} }, diff --git a/client/src/app/+search/search.component.ts b/client/src/app/+search/search.component.ts index b9ec6dbcc..62b1c4446 100644 --- a/client/src/app/+search/search.component.ts +++ b/client/src/app/+search/search.component.ts @@ -248,11 +248,11 @@ export class SearchComponent implements OnInit, OnDestroy { } private updateTitle () { - const suffix = this.currentSearch - ? ' ' + this.currentSearch - : '' + const title = this.currentSearch + ? $localize`Search ${this.currentSearch}` + : $localize`Search` - this.metaService.setTitle($localize`Search` + suffix) + this.metaService.setTitle(title) } private updateUrlFromAdvancedSearch () { diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index 212e2f867..780db79b0 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html @@ -72,10 +72,10 @@
- {videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}} + {videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}} - {channelVideosCount, plural, =1 {1 videos} other {{{ channelVideosCount }} videos}} + {channelVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ channelVideosCount }} videos}}
diff --git a/client/src/app/+videos/video-list/videos-list-common-page.component.ts b/client/src/app/+videos/video-list/videos-list-common-page.component.ts index d2782036b..c8fa8ef30 100644 --- a/client/src/app/+videos/video-list/videos-list-common-page.component.ts +++ b/client/src/app/+videos/video-list/videos-list-common-page.component.ts @@ -204,13 +204,28 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable if ([ 'hot', 'trending', 'likes', 'views' ].includes(sanitizedSort)) { this.title = $localize`Trending` - if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos` - if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes` - if (sanitizedSort === 'views') this.titleTooltip = undefined + if (sanitizedSort === 'hot') { + this.titleTooltip = $localize`Videos with the most interactions for recent videos` + return + } + + if (sanitizedSort === 'likes') { + this.titleTooltip = $localize`Videos that have the most likes` + return + } + + if (sanitizedSort === 'views') { + this.titleTooltip = undefined + return + } if (sanitizedSort === 'trending') { - if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours` - else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days` + if (this.trendingDays === 1) { + this.titleTooltip = $localize`Videos with the most views during the last 24 hours` + return + } + + this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days` } return diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts index 17053811c..86c7484a5 100644 --- a/client/src/app/core/rest/rest-extractor.service.ts +++ b/client/src/app/core/rest/rest-extractor.service.ts @@ -34,49 +34,17 @@ export class RestExtractor { return target } - handleError (err: any) { - let errorMessage + redirectTo404IfNotFound (obj: { status: number }, type: 'video' | 'other', status = [ HttpStatusCode.NOT_FOUND_404 ]) { + if (obj?.status && status.includes(obj.status)) { + // Do not use redirectService to avoid circular dependencies + this.router.navigate([ '/404' ], { state: { type, obj }, skipLocationChange: true }) + } - if (err.error instanceof Error) { - // A client-side or network error occurred. Handle it accordingly. - errorMessage = err.error.detail || err.error.title - console.error('An error occurred:', errorMessage) - } else if (typeof err.error === 'string') { - errorMessage = err.error - } else if (err.status !== undefined) { - // A server-side error occurred. - if (err.error?.errors) { - const errors = err.error.errors - const errorsArray: string[] = [] - - Object.keys(errors).forEach(key => { - errorsArray.push(errors[key].msg) - }) - - errorMessage = errorsArray.join('. ') - } else if (err.error?.error) { - errorMessage = err.error.error - } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { - // eslint-disable-next-line max-len - errorMessage = $localize`Media is too large for the server. Please contact you administrator if you want to increase the limit size.` - } else if (err.status === HttpStatusCode.TOO_MANY_REQUESTS_429) { - const secondsLeft = err.headers.get('retry-after') - if (secondsLeft) { - const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60) - errorMessage = $localize`Too many attempts, please try again after ${minutesLeft} minutes.` - } else { - errorMessage = $localize`Too many attempts, please try again later.` - } - } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { - errorMessage = $localize`Server error. Please retry later.` - } + return observableThrowError(() => obj) + } - errorMessage = errorMessage || 'Unknown error.' - console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) - } else { - console.error(err) - errorMessage = err - } + handleError (err: any) { + const errorMessage = this.buildErrorMessage(err) const errorObj: { message: string, status: string, body: string } = { message: errorMessage, @@ -92,12 +60,63 @@ export class RestExtractor { return observableThrowError(() => errorObj) } - redirectTo404IfNotFound (obj: { status: number }, type: 'video' | 'other', status = [ HttpStatusCode.NOT_FOUND_404 ]) { - if (obj?.status && status.includes(obj.status)) { - // Do not use redirectService to avoid circular dependencies - this.router.navigate([ '/404' ], { state: { type, obj }, skipLocationChange: true }) + private buildErrorMessage (err: any) { + if (err.error instanceof Error) { + // A client-side or network error occurred. Handle it accordingly. + const errorMessage = err.error.detail || err.error.title + console.error('An error occurred:', errorMessage) + + return errorMessage } - return observableThrowError(() => obj) + if (typeof err.error === 'string') { + return err.error + } + + if (err.status !== undefined) { + const errorMessage = this.buildServerErrorMessage(err) + console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) + + return errorMessage + } + + console.error(err) + return err + } + + private buildServerErrorMessage (err: any) { + // A server-side error occurred. + if (err.error?.errors) { + const errors = err.error.errors + + return Object.keys(errors) + .map(key => errors[key].msg) + .join('. ') + } + + if (err.error?.error) { + return err.error.error + } + + if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { + return $localize`Media is too large for the server. Please contact you administrator if you want to increase the limit size.` + } + + if (err.status === HttpStatusCode.TOO_MANY_REQUESTS_429) { + const secondsLeft = err.headers.get('retry-after') + + if (secondsLeft) { + const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60) + return $localize`Too many attempts, please try again after ${minutesLeft} minutes.` + } + + return $localize`Too many attempts, please try again later.` + } + + if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { + return $localize`Server error. Please retry later.` + } + + return $localize`Unknown server error` } } diff --git a/client/src/app/helpers/i18n-utils.ts b/client/src/app/helpers/i18n-utils.ts index bbfb12959..2017a31ea 100644 --- a/client/src/app/helpers/i18n-utils.ts +++ b/client/src/app/helpers/i18n-utils.ts @@ -1,4 +1,5 @@ import { environment } from '../../environments/environment' +import IntlMessageFormat from 'intl-messageformat' function isOnDevLocale () { return environment.production === false && window.location.search === '?lang=fr' @@ -8,7 +9,31 @@ function getDevLocale () { return 'fr-FR' } +function prepareIcu (icu: string) { + let alreadyWarned = false + + try { + const msg = new IntlMessageFormat(icu, $localize.locale) + + return (context: { [id: string]: number | string }, fallback: string) => { + try { + return msg.format(context) as string + } catch (err) { + if (!alreadyWarned) console.warn('Cannot format ICU %s.', icu, err) + + alreadyWarned = true + return fallback + } + } + } catch (err) { + console.warn('Cannot build intl message %s.', icu, err) + + return (_context: unknown, fallback: string) => fallback + } +} + export { getDevLocale, + prepareIcu, isOnDevLocale } diff --git a/client/src/app/helpers/utils/upload.ts b/client/src/app/helpers/utils/upload.ts index a3fce7fee..5c2600a0d 100644 --- a/client/src/app/helpers/utils/upload.ts +++ b/client/src/app/helpers/utils/upload.ts @@ -2,36 +2,43 @@ import { HttpErrorResponse } from '@angular/common/http' import { Notifier } from '@app/core' import { HttpStatusCode } from '@shared/models' -function genericUploadErrorHandler (parameters: { +function genericUploadErrorHandler (options: { err: Pick name: string notifier: Notifier sticky?: boolean }) { - const { err, name, notifier, sticky } = { sticky: false, ...parameters } - const title = $localize`The upload failed` - let message = err.message - - if (err instanceof ErrorEvent) { // network error - message = $localize`The connection was interrupted` - notifier.error(message, title, null, sticky) - } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { - message = $localize`The server encountered an error` - notifier.error(message, title, null, sticky) - } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { - message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` - notifier.error(message, title, null, sticky) - } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { - const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G' - message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})` - notifier.error(message, title, null, sticky) - } else { - notifier.error(err.message, title) - } + const { err, name, notifier, sticky = false } = options + const title = $localize`Upload failed` + const message = buildMessage(name, err) + notifier.error(message, title, null, sticky) return message } export { genericUploadErrorHandler } + +// --------------------------------------------------------------------------- + +function buildMessage (name: string, err: Pick) { + if (err instanceof ErrorEvent) { // network error + return $localize`The connection was interrupted` + } + + if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { + return $localize`The server encountered an error` + } + + if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { + return $localize`Your ${name} file couldn't be transferred before the server proxy timeout` + } + + if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { + const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G' + return $localize`Your ${name} file was too large (max. size: ${maxFileSize})` + } + + return err.message +} diff --git a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts index ebf7b77a6..2c3226f68 100644 --- a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts +++ b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts @@ -1,6 +1,7 @@ import { Component, forwardRef, Input } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { Notifier } from '@app/core' +import { prepareIcu } from '@app/helpers' import { SelectOptionsItem } from '../../../../types/select-options-item.model' import { ItemSelectCheckboxValue } from './select-checkbox.component' @@ -78,7 +79,12 @@ export class SelectCheckboxAllComponent implements ControlValueAccessor { if (!outputItems) return true if (outputItems.length >= this.maxItems) { - this.notifier.error($localize`You can't select more than ${this.maxItems} items`) + this.notifier.error( + prepareIcu($localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`)( + { maxItems: this.maxItems }, + $localize`You can't select more than ${this.maxItems} items` + ) + ) return false } diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.ts b/client/src/app/shared/shared-instance/instance-features-table.component.ts index 6335de450..e405c5790 100644 --- a/client/src/app/shared/shared-instance/instance-features-table.component.ts +++ b/client/src/app/shared/shared-instance/instance-features-table.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit } from '@angular/core' import { ServerService } from '@app/core' +import { prepareIcu } from '@app/helpers' import { ServerConfig } from '@shared/models' import { PeertubeModalService } from '../shared-main/peertube-modal/peertube-modal.service' @@ -65,15 +66,20 @@ export class InstanceFeaturesTableComponent implements OnInit { private getApproximateTime (seconds: number) { const hours = Math.floor(seconds / 3600) - let pluralSuffix = '' - if (hours > 1) pluralSuffix = 's' - if (hours > 0) return `~ ${hours} hour${pluralSuffix}` - const minutes = Math.floor(seconds % 3600 / 60) + if (hours !== 0) { + return prepareIcu($localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`)( + { hours }, + $localize`~ ${hours} hours` + ) + } - if (minutes === 1) return $localize`~ 1 minute` + const minutes = Math.floor(seconds % 3600 / 60) - return $localize`~ ${minutes} minutes` + return prepareIcu($localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`)( + { minutes }, + $localize`~ ${minutes} minutes` + ) } private buildQuotaHelpIndication () { diff --git a/client/src/app/shared/shared-main/angular/from-now.pipe.ts b/client/src/app/shared/shared-main/angular/from-now.pipe.ts index d62c1f88e..dc6a25e83 100644 --- a/client/src/app/shared/shared-main/angular/from-now.pipe.ts +++ b/client/src/app/shared/shared-main/angular/from-now.pipe.ts @@ -1,37 +1,51 @@ import { Pipe, PipeTransform } from '@angular/core' +import { prepareIcu } from '@app/helpers' // Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site @Pipe({ name: 'myFromNow' }) export class FromNowPipe implements PipeTransform { + private yearICU = prepareIcu($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`) + private monthICU = prepareIcu($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`) + private weekICU = prepareIcu($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`) + private dayICU = prepareIcu($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`) + private hourICU = prepareIcu($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`) + transform (arg: number | Date | string) { const argDate = new Date(arg) const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) let interval = Math.floor(seconds / 31536000) - if (interval > 1) return $localize`${interval} years ago` - if (interval === 1) return $localize`1 year ago` + if (interval >= 1) { + return this.yearICU({ interval }, $localize`${interval} year(s) ago`) + } interval = Math.floor(seconds / 2419200) // 12 months = 360 days, but a year ~ 365 days // Display "1 year ago" rather than "12 months ago" if (interval >= 12) return $localize`1 year ago` - if (interval > 1) return $localize`${interval} months ago` - if (interval === 1) return $localize`1 month ago` + + if (interval >= 1) { + return this.monthICU({ interval }, $localize`${interval} month(s) ago`) + } interval = Math.floor(seconds / 604800) // 4 weeks ~ 28 days, but our month is 30 days // Display "1 month ago" rather than "4 weeks ago" if (interval >= 4) return $localize`1 month ago` - if (interval > 1) return $localize`${interval} weeks ago` - if (interval === 1) return $localize`1 week ago` + + if (interval >= 1) { + return this.weekICU({ interval }, $localize`${interval} week(s) ago`) + } interval = Math.floor(seconds / 86400) - if (interval > 1) return $localize`${interval} days ago` - if (interval === 1) return $localize`1 day ago` + if (interval >= 1) { + return this.dayICU({ interval }, $localize`${interval} day(s) ago`) + } interval = Math.floor(seconds / 3600) - if (interval > 1) return $localize`${interval} hours ago` - if (interval === 1) return $localize`1 hour ago` + if (interval >= 1) { + return this.hourICU({ interval }, $localize`${interval} hour(s) ago`) + } interval = Math.floor(seconds / 60) if (interval >= 1) return $localize`${interval} min ago` diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 022bb95ad..2e4ab87d7 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -1,6 +1,6 @@ import { AuthUser } from '@app/core' import { User } from '@app/core/users/user.model' -import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' +import { durationToString, prepareIcu, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' import { Actor } from '@app/shared/shared-main/account/actor.model' import { buildVideoWatchPath } from '@shared/core-utils' import { peertubeTranslate } from '@shared/core-utils/i18n' @@ -19,6 +19,9 @@ import { } from '@shared/models' export class Video implements VideoServerModel { + private static readonly viewsICU = prepareIcu($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`) + private static readonly viewersICU = prepareIcu($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`) + byVideoChannel: string byAccount: string @@ -269,12 +272,10 @@ export class Video implements VideoServerModel { } getExactNumberOfViews () { - if (this.views < 1000) return '' - if (this.isLive) { - return $localize`${this.views} viewers` + return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) } - return $localize`${this.views} views` + return Video.viewsICU({ views: this.views }, $localize`{${this.views} view(s)}`) } } diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts index 9edfac388..8b483499a 100644 --- a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts +++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts @@ -1,6 +1,7 @@ import { forkJoin } from 'rxjs' import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Notifier } from '@app/core' +import { prepareIcu } from '@app/helpers' import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' @@ -63,9 +64,16 @@ export class UserBanModalComponent extends FormReactive implements OnInit { forkJoin(observables) .subscribe({ next: () => { - const message = Array.isArray(this.usersToBan) - ? $localize`${this.usersToBan.length} users banned.` - : $localize`User ${this.usersToBan.username} banned.` + let message: string + + if (Array.isArray(this.usersToBan)) { + message = prepareIcu($localize`{count, plural, =1 {1 user} other {{count} users}} banned.`)( + { count: this.usersToBan.length }, + $localize`${this.usersToBan.length} users banned.` + ) + } else { + message = $localize`User ${this.usersToBan.username} banned.` + } this.notifier.success(message) @@ -79,7 +87,12 @@ export class UserBanModalComponent extends FormReactive implements OnInit { } getModalTitle () { - if (Array.isArray(this.usersToBan)) return $localize`Ban ${this.usersToBan.length} users` + if (Array.isArray(this.usersToBan)) { + return prepareIcu($localize`Ban {count, plural, =1 {1 user} other {{count} users}}`)( + { count: this.usersToBan.length }, + $localize`Ban ${this.usersToBan.length} users` + ) + } return $localize`Ban "${this.usersToBan.username}"` } diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts index 787318c2c..c69a45c25 100644 --- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts +++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts @@ -100,7 +100,8 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges { return } - const message = $localize`If you remove user ${user.username}, you won't be able to create another with the same username!` + // eslint-disable-next-line max-len + const message = $localize`If you remove this user, you won't be able to create another user or channel with ${user.username} username!` const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`) if (res === false) return diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts index 400913f02..e14473b89 100644 --- a/client/src/app/shared/shared-moderation/video-block.component.ts +++ b/client/src/app/shared/shared-moderation/video-block.component.ts @@ -1,5 +1,6 @@ import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Notifier } from '@app/core' +import { prepareIcu } from '@app/helpers' import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { Video } from '@app/shared/shared-main' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' @@ -80,9 +81,10 @@ export class VideoBlockComponent extends FormReactive implements OnInit { this.videoBlocklistService.blockVideo(options) .subscribe({ next: () => { - const message = this.isMultiple - ? $localize`Blocked ${this.videos.length} videos.` - : $localize`Blocked ${this.getSingleVideo().name}` + const message = prepareIcu($localize`{count, plural, =1 {Blocked {videoName}} other {Blocked {count} videos}}.`)( + { count: this.videos.length, videoName: this.getSingleVideo().name }, + $localize`Blocked ${this.videos.length} videos.` + ) this.notifier.success(message) this.hide() diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.html b/client/src/app/shared/shared-user-settings/user-video-settings.component.html index 446ade445..836972a33 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.html +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.html @@ -30,7 +30,7 @@
- +
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts index 42c472579..534a78b3f 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts @@ -175,7 +175,7 @@ export class VideoMiniatureComponent implements OnInit { if (video.scheduledUpdate) { const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId) - return $localize`Publication scheduled on ` + updateAt + return $localize`Publication scheduled on ${updateAt}` } if (video.state.id === VideoState.TRANSCODING_FAILED) { -- cgit v1.2.3