diff --git a/client/angular.json b/client/angular.json index d929248d4..9b069422f 100644 --- a/client/angular.json +++ b/client/angular.json @@ -199,7 +199,11 @@ "is-plain-object", "parse-srcset", "deepmerge", - "core-js/features/reflect" + "core-js/features/reflect", + "@formatjs/intl-locale/polyfill", + "@formatjs/intl-locale/should-polyfill", + "@formatjs/intl-pluralrules/polyfill-force", + "@formatjs/intl-pluralrules/should-polyfill" ], "scripts": [], "vendorChunk": true, diff --git a/client/package.json b/client/package.json index 202a0f836..564e56ae7 100644 --- a/client/package.json +++ b/client/package.json @@ -47,6 +47,8 @@ "@angular/service-worker": "^16.0.2", "@babel/core": "^7.18.5", "@babel/preset-env": "^7.18.2", + "@formatjs/intl-locale": "^3.3.1", + "@formatjs/intl-pluralrules": "^5.2.2", "@ng-bootstrap/ng-bootstrap": "^14.0.1", "@ng-select/ng-select": "^10.0.3", "@ngx-loading-bar/core": "^6.0.0", 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 628c2d102..42c0e6dc2 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,6 +1,6 @@ import { Injectable } from '@angular/core' import { FormGroup } from '@angular/forms' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' export type ResolutionOption = { id: string @@ -99,10 +99,7 @@ export class EditConfigurationService { return { value, atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible - unit: prepareIcu($localize`{value, plural, =1 {thread} other {threads}}`)( - { value }, - $localize`threads` - ) + unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value }) } } } diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts index cebb2e1a2..618892242 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts @@ -1,7 +1,7 @@ import { SortMeta } from 'primeng/api' import { Component, OnInit } from '@angular/core' import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { InstanceFollowService } from '@app/shared/shared-instance' import { DropdownAction } from '@app/shared/shared-main' @@ -63,9 +63,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O .subscribe({ next: () => { // eslint-disable-next-line max-len - const message = prepareIcu($localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( - { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, - $localize`Follow requests accepted` + const message = formatICU( + $localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`, + { count: follows.length, followerName: this.buildFollowerName(follows[0]) } ) this.notifier.success(message) @@ -78,9 +78,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O async rejectFollower (follows: ActorFollow[]) { // eslint-disable-next-line max-len - const message = prepareIcu($localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( - { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, - $localize`Do you really want to reject these follow requests?` + const message = formatICU( + $localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`, + { count: follows.length, followerName: this.buildFollowerName(follows[0]) } ) const res = await this.confirmService.confirm(message, $localize`Reject`) @@ -90,9 +90,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O .subscribe({ next: () => { // eslint-disable-next-line max-len - const message = prepareIcu($localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( - { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, - $localize`Follow requests rejected` + const message = formatICU( + $localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`, + { count: follows.length, followerName: this.buildFollowerName(follows[0]) } ) this.notifier.success(message) @@ -110,9 +110,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O message += '<br /><br />' // eslint-disable-next-line max-len - message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( - icuParams, - $localize`Do you really want to delete these follow requests?` + message += formatICU( + $localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`, + icuParams ) const res = await this.confirmService.confirm(message, $localize`Delete`) @@ -122,9 +122,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O .subscribe({ next: () => { // eslint-disable-next-line max-len - const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( - icuParams, - $localize`Follow requests removed` + const message = formatICU( + $localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`, + icuParams ) this.notifier.success(message) 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 8f74e82a6..54b3cebc5 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,6 +1,6 @@ import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Notifier } from '@app/core' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' import { InstanceFollowService } from '@app/shared/shared-instance' @@ -62,9 +62,9 @@ export class FollowModalComponent extends FormReactive implements OnInit { .subscribe({ next: () => { this.notifier.success( - prepareIcu($localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`)( - { count: hostsOrHandles.length }, - $localize`Follow request(s) sent!` + formatICU( + $localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`, + { count: hostsOrHandles.length } ) ) diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts index 71f2fbe66..6c8723c16 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.ts +++ b/client/src/app/+admin/follows/following-list/following-list.component.ts @@ -6,7 +6,7 @@ import { InstanceFollowService } from '@app/shared/shared-instance' import { ActorFollow } from '@shared/models' import { FollowModalComponent } from './follow-modal.component' import { DropdownAction } from '@app/shared/shared-main' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' @Component({ templateUrl: './following-list.component.html', @@ -64,9 +64,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O async removeFollowing (follows: ActorFollow[]) { const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) } - const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)( - icuParams, - $localize`Do you really want to unfollow these entries?` + const message = formatICU( + $localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`, + icuParams ) const res = await this.confirmService.confirm(message, $localize`Unfollow`) @@ -76,9 +76,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O .subscribe({ next: () => { // eslint-disable-next-line max-len - const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)( - icuParams, - $localize`You are not following them anymore.` + const message = formatICU( + $localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`, + icuParams ) this.notifier.success(message) diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts index 3ca1ceab8..35d9d13d7 100644 --- a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts +++ b/client/src/app/+admin/moderation/registration-list/registration-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 { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { DropdownAction } from '@app/shared/shared-main' import { UserRegistration, UserRegistrationState } from '@shared/models' @@ -121,9 +121,9 @@ export class RegistrationListComponent extends RestTable <UserRegistration> impl const icuParams = { count: registrations.length, username: registrations[0].username } // eslint-disable-next-line max-len - const message = prepareIcu($localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`)( - icuParams, - $localize`Do you really want to delete these registration requests?` + const message = formatICU( + $localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`, + icuParams ) const res = await this.confirmService.confirm(message, $localize`Delete`) @@ -133,9 +133,9 @@ export class RegistrationListComponent extends RestTable <UserRegistration> impl .subscribe({ next: () => { // eslint-disable-next-line max-len - const message = prepareIcu($localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`)( - icuParams, - $localize`Registration requests removed` + const message = formatICU( + $localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`, + icuParams ) this.notifier.success(message) 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 28efdc076..b77072665 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,7 +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' +import { formatICU } from '@app/helpers' @Component({ selector: 'my-video-comment-list', @@ -146,9 +146,9 @@ export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> imp .subscribe({ next: () => { this.notifier.success( - prepareIcu($localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`)( - { count: commentArgs.length }, - $localize`${commentArgs.length} comment(s) deleted.` + formatICU( + $localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`, + { count: commentArgs.length } ) ) 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 19420b748..5d5abf6f4 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, prepareIcu } from '@app/helpers' +import { formatICU, 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' @@ -210,9 +210,9 @@ export class UserListComponent extends RestTable <User> implements OnInit { async unbanUsers (users: User[]) { 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?` + formatICU( + $localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`, + { count: users.length } ), $localize`Unban` ) @@ -223,9 +223,9 @@ export class UserListComponent extends RestTable <User> implements OnInit { .subscribe({ next: () => { this.notifier.success( - prepareIcu($localize`{count, plural, =1 {1 user unbanned.} other {{count} users unbanned.}}`)( - { count: users.length }, - $localize`${users.length} users unbanned.` + formatICU( + $localize`{count, plural, =1 {1 user unbanned.} other {{count} users unbanned.}}`, + { count: users.length } ) ) this.reloadData() @@ -252,9 +252,9 @@ export class UserListComponent extends RestTable <User> implements OnInit { .subscribe({ next: () => { this.notifier.success( - prepareIcu($localize`{count, plural, =1 {1 user deleted.} other {{count} users deleted.}}`)( - { count: users.length }, - $localize`${users.length} users deleted.` + formatICU( + $localize`{count, plural, =1 {1 user deleted.} other {{count} users deleted.}}`, + { count: users.length } ) ) @@ -270,9 +270,9 @@ export class UserListComponent extends RestTable <User> implements OnInit { .subscribe({ next: () => { this.notifier.success( - prepareIcu($localize`{count, plural, =1 {1 user email set as verified.} other {{count} user emails set as verified.}}`)( - { count: users.length }, - $localize`${users.length} users email set as verified.` + formatICU( + $localize`{count, plural, =1 {1 user email set as verified.} other {{count} user emails set as verified.}}`, + { count: users.length } ) ) 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 ebf82ce16..e9c526193 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.ts +++ b/client/src/app/+admin/overview/videos/video-list.component.ts @@ -3,7 +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 { formatICU } 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' @@ -219,9 +219,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit { } private async removeVideos (videos: Video[]) { - 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 message = formatICU( + $localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`, + { count: videos.length } ) const res = await this.confirmService.confirm(message, $localize`Delete`) @@ -231,9 +231,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit { .subscribe({ next: () => { this.notifier.success( - prepareIcu($localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`)( - { count: videos.length }, - $localize`Deleted ${videos.length} videos.` + formatICU( + $localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`, + { count: videos.length } ) ) @@ -249,9 +249,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit { .subscribe({ next: () => { this.notifier.success( - prepareIcu($localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`)( - { count: videos.length }, - $localize`Unblocked ${videos.length} videos.` + formatICU( + $localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`, + { count: videos.length } ) ) @@ -267,15 +267,15 @@ export class VideoListComponent extends RestTable <Video> implements OnInit { 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?` + message = formatICU( + $localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`, + { count: videos.length } ) } 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?` + message = formatICU( + $localize`Are you sure you want to delete WebTorrent files of {count, plural, =1 {1 video} other {{count} videos}}?`, + { count: videos.length } ) } diff --git a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts index 8ba956eb8..8994c1d00 100644 --- a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts +++ b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts @@ -1,7 +1,7 @@ import { SortMeta } from 'primeng/api' import { Component, OnInit } from '@angular/core' import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' import { DropdownAction } from '@app/shared/shared-main' import { RunnerJob, RunnerJobState } from '@shared/models' import { RunnerJobFormatted, RunnerService } from '../runner.service' @@ -57,9 +57,10 @@ export class RunnerJobListComponent extends RestTable <RunnerJob> implements OnI } async cancelJobs (jobs: RunnerJob[]) { - const message = prepareIcu( - $localize`Do you really want to cancel {count, plural, =1 {this job} other {{count} jobs}}? Children jobs will also be cancelled.` - )({ count: jobs.length }, $localize`Do you really want to cancel these jobs? Children jobs will also be cancelled.`) + const message = formatICU( + $localize`Do you really want to cancel {count, plural, =1 {this job} other {{count} jobs}}? Children jobs will also be cancelled.`, + { count: jobs.length } + ) const res = await this.confirmService.confirm(message, $localize`Cancel`) 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 57b8bdf7d..1827d6a0b 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 @@ -5,7 +5,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, prepareIcu } from '@app/helpers' +import { immutableAssign, formatICU } 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' @@ -184,9 +184,9 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { .map(([ k, _v ]) => parseInt(k, 10)) const res = await this.confirmService.confirm( - 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?` + formatICU( + $localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`, + { length: toDeleteVideosIds.length } ), $localize`Delete` ) @@ -205,9 +205,9 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { .subscribe({ next: () => { 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.` + formatICU( + $localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`, + { length: toDeleteVideosIds.length } ) ) diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index 7e4fac730..9339865f1 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -12,13 +12,14 @@ import { CoreModule, PluginService, RedirectService, ServerService } from './cor import { EmptyComponent } from './empty.component' import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header' import { HighlightPipe } from './header/highlight.pipe' +import { polyfillICU } from './helpers' import { LanguageChooserComponent, MenuComponent, NotificationComponent } from './menu' +import { AccountSetupWarningModalComponent } from './modal/account-setup-warning-modal.component' +import { AdminWelcomeModalComponent } from './modal/admin-welcome-modal.component' import { ConfirmComponent } from './modal/confirm.component' import { CustomModalComponent } from './modal/custom-modal.component' import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component' import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component' -import { AdminWelcomeModalComponent } from './modal/admin-welcome-modal.component' -import { AccountSetupWarningModalComponent } from './modal/account-setup-warning-modal.component' import { SharedActorImageModule } from './shared/shared-actor-image/shared-actor-image.module' import { SharedFormModule } from './shared/shared-forms' import { SharedGlobalIconModule } from './shared/shared-icons' @@ -90,6 +91,11 @@ export function loadConfigFactory (server: ServerService, pluginService: PluginS useFactory: loadConfigFactory, deps: [ ServerService, PluginService, RedirectService ], multi: true + }, + { + provide: APP_INITIALIZER, + useFactory: () => polyfillICU, + multi: true } ] }) diff --git a/client/src/app/helpers/i18n-utils.ts b/client/src/app/helpers/i18n-utils.ts index b7d73d16b..9e22bb4c1 100644 --- a/client/src/app/helpers/i18n-utils.ts +++ b/client/src/app/helpers/i18n-utils.ts @@ -1,4 +1,6 @@ import IntlMessageFormat from 'intl-messageformat' +import { shouldPolyfill as shouldPolyfillLocale } from '@formatjs/intl-locale/should-polyfill' +import { shouldPolyfill as shouldPolyfillPlural } from '@formatjs/intl-pluralrules/should-polyfill' import { logger } from '@root-helpers/logger' import { environment } from '../../environments/environment' @@ -10,31 +12,68 @@ function getDevLocale () { return 'fr-FR' } -function prepareIcu (icu: string) { - let alreadyWarned = false +async function polyfillICU () { + // Important to be in this order, Plural needs Locale (https://formatjs.io/docs/polyfills/intl-pluralrules) + await polyfillICULocale() + await polyfillICUPlural() +} +async function polyfillICULocale () { + // This locale is supported + if (shouldPolyfillLocale()) { + // TODO: remove, it's only needed to support Plural polyfill and so iOS 12 + console.log('Loading Intl Locale polyfill for ' + $localize.locale) + + await import('@formatjs/intl-locale/polyfill') + } +} + +async function polyfillICUPlural () { + const unsupportedLocale = shouldPolyfillPlural($localize.locale) + + // This locale is supported + if (!unsupportedLocale) { + return + } + + // TODO: remove, it's only needed to support iOS 12 + console.log('Loading Intl Plural rules polyfill for ' + $localize.locale) + + // Load the polyfill 1st BEFORE loading data + await import('@formatjs/intl-pluralrules/polyfill-force') + // Degraded mode, so only load the en local data + await import(`@formatjs/intl-pluralrules/locale-data/en.js`) +} + +// --------------------------------------------------------------------------- + +const icuCache = new Map<string, IntlMessageFormat>() +const icuWarnings = new Set<string>() +const fallback = 'String translation error' + +function formatICU (icu: string, context: { [id: string]: number | string }) { try { - const msg = new IntlMessageFormat(icu, $localize.locale) + let msg = icuCache.get(icu) - return (context: { [id: string]: number | string }, fallback: string) => { - try { - return msg.format(context) as string - } catch (err) { - if (!alreadyWarned) logger.warn(`Cannot format ICU ${icu}.`, err) - - alreadyWarned = true - return fallback - } + if (!msg) { + msg = new IntlMessageFormat(icu, $localize.locale) + icuCache.set(icu, msg) } - } catch (err) { - logger.warn(`Cannot build intl message ${icu}.`, err) - return (_context: unknown, fallback: string) => fallback + return msg.format(context) as string + } catch (err) { + if (!icuWarnings.has(icu)) { + logger.warn(`Cannot format ICU ${icu}.`, err) + } + + icuWarnings.add(icu) + return fallback } } export { getDevLocale, - prepareIcu, + polyfillICU, + formatICU, isOnDevLocale } 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 2c3226f68..8b6cd091a 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,7 +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 { formatICU } from '@app/helpers' import { SelectOptionsItem } from '../../../../types/select-options-item.model' import { ItemSelectCheckboxValue } from './select-checkbox.component' @@ -80,9 +80,9 @@ export class SelectCheckboxAllComponent implements ControlValueAccessor { if (outputItems.length >= this.maxItems) { 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` + formatICU( + $localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`, + { maxItems: this.maxItems } ) ) 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 2e63f6c17..ab1b1458a 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,6 +1,6 @@ import { Component, OnInit } from '@angular/core' import { ServerService } from '@app/core' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' import { ServerConfig } from '@shared/models' @Component({ @@ -71,17 +71,17 @@ export class InstanceFeaturesTableComponent implements OnInit { const hours = Math.floor(seconds / 3600) if (hours !== 0) { - return prepareIcu($localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`)( - { hours }, - $localize`~ ${hours} hours` + return formatICU( + $localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`, + { hours } ) } const minutes = Math.floor(seconds % 3600 / 60) - return prepareIcu($localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`)( - { minutes }, - $localize`~ ${minutes} minutes` + return formatICU( + $localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`, + { minutes } ) } 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 dc6a25e83..4ff244bbb 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,14 +1,9 @@ import { Pipe, PipeTransform } from '@angular/core' -import { prepareIcu } from '@app/helpers' +import { formatICU } 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) @@ -16,7 +11,7 @@ export class FromNowPipe implements PipeTransform { let interval = Math.floor(seconds / 31536000) if (interval >= 1) { - return this.yearICU({ interval }, $localize`${interval} year(s) ago`) + return formatICU($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`, { interval }) } interval = Math.floor(seconds / 2419200) @@ -25,7 +20,7 @@ export class FromNowPipe implements PipeTransform { if (interval >= 12) return $localize`1 year ago` if (interval >= 1) { - return this.monthICU({ interval }, $localize`${interval} month(s) ago`) + return formatICU($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`, { interval }) } interval = Math.floor(seconds / 604800) @@ -34,17 +29,17 @@ export class FromNowPipe implements PipeTransform { if (interval >= 4) return $localize`1 month ago` if (interval >= 1) { - return this.weekICU({ interval }, $localize`${interval} week(s) ago`) + return formatICU($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`, { interval }) } interval = Math.floor(seconds / 86400) if (interval >= 1) { - return this.dayICU({ interval }, $localize`${interval} day(s) ago`) + return formatICU($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`, { interval }) } interval = Math.floor(seconds / 3600) if (interval >= 1) { - return this.hourICU({ interval }, $localize`${interval} hour(s) ago`) + return formatICU($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`, { interval }) } interval = Math.floor(seconds / 60) 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 24c00c3d5..e94087dbe 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, prepareIcu } from '@app/helpers' +import { durationToString, formatICU, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers' import { Actor } from '@app/shared/shared-main/account/actor.model' import { buildVideoWatchPath, getAllFiles } from '@shared/core-utils' import { peertubeTranslate } from '@shared/core-utils/i18n' @@ -19,9 +19,6 @@ 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 @@ -290,9 +287,9 @@ export class Video implements VideoServerModel { getExactNumberOfViews () { if (this.isLive) { - return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) + return formatICU($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`, { viewers: this.viewers }) } - return Video.viewsICU({ views: this.views }, $localize`{${this.views} view(s)}`) + return formatICU($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`, { views: this.views }) } } 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 27dcf043a..34295c34a 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,7 +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 { formatICU } from '@app/helpers' import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' @@ -67,9 +67,9 @@ export class UserBanModalComponent extends FormReactive implements OnInit { let message: string if (Array.isArray(this.usersToBan)) { - message = prepareIcu($localize`{count, plural, =1 {1 user banned.} other {{count} users banned.}}`)( - { count: this.usersToBan.length }, - $localize`${this.usersToBan.length} users banned.` + message = formatICU( + $localize`{count, plural, =1 {1 user banned.} other {{count} users banned.}}`, + { count: this.usersToBan.length } ) } else { message = $localize`User ${this.usersToBan.username} banned.` @@ -88,9 +88,9 @@ export class UserBanModalComponent extends FormReactive implements OnInit { getModalTitle () { 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 formatICU( + $localize`Ban {count, plural, =1 {1 user} other {{count} users}}`, + { count: this.usersToBan.length } ) } 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 3ff53443a..0137def89 100644 --- a/client/src/app/shared/shared-moderation/video-block.component.ts +++ b/client/src/app/shared/shared-moderation/video-block.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Notifier } from '@app/core' -import { prepareIcu } from '@app/helpers' +import { formatICU } from '@app/helpers' import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' import { Video } from '@app/shared/shared-main' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' @@ -81,9 +81,9 @@ export class VideoBlockComponent extends FormReactive implements OnInit { this.videoBlocklistService.blockVideo(options) .subscribe({ next: () => { - 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.` + const message = formatICU( + $localize`{count, plural, =1 {Blocked {videoName}.} other {Blocked {count} videos.}}`, + { count: this.videos.length, videoName: this.getSingleVideo().name } ) this.notifier.success(message) diff --git a/client/tsconfig.json b/client/tsconfig.json index 785ed1c6c..f6409402a 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -89,8 +89,7 @@ ], "exclude": [ "../node_modules", - "../server", - "node_modules" + "../server" ], "angularCompilerOptions": { "strictInjectionParameters": true, diff --git a/client/yarn.lock b/client/yarn.lock index aeb13a7b5..282c851ec 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1647,6 +1647,14 @@ "@formatjs/intl-localematcher" "0.2.32" tslib "^2.4.0" +"@formatjs/ecma402-abstract@1.16.0": + version "1.16.0" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.16.0.tgz#15a0baa8401880d4010eb93440d996e896ca251c" + integrity sha512-qIH2cmG/oHGrVdApbqDf6/YR+B2A4NdkBjKLeq369OMVkqMFsC5oPSP1xpiyL1cAn+PbNEZHxwOVMYD/C76c6g== + dependencies: + "@formatjs/intl-localematcher" "0.3.0" + tslib "^2.4.0" + "@formatjs/fast-memoize@2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.0.1.tgz#f15aaa73caad5562899c69bdcad8db82adcd3b0b" @@ -1671,6 +1679,30 @@ "@formatjs/ecma402-abstract" "1.15.0" tslib "^2.4.0" +"@formatjs/intl-enumerator@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-enumerator/-/intl-enumerator-1.3.1.tgz#32f0a3b5aece244977aad16fe1cd5c205defc7f8" + integrity sha512-UMuD1tNRb8JC+mZo0KeuSntJNie0+TLxXl/1QxRIRMR7z2UuJgphrK/UTUibAx9hjywL1qGdNNhD6QX//pvNyA== + dependencies: + tslib "^2.4.0" + +"@formatjs/intl-getcanonicallocales@2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.2.1.tgz#0d70251b16bec06c1c4a40b37892ea5b51e3a2bb" + integrity sha512-KooqmyY+Mhq3ioASPzoU6p6Cy9Mx+cWSVQSP6lF+vEW2tiaN90ti08cp82p1dzFschenduOYgPKrNcBpsDi6+g== + dependencies: + tslib "^2.4.0" + +"@formatjs/intl-locale@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-3.3.1.tgz#2b86e85319913e0bedfcd64884be33bc4bcef73e" + integrity sha512-Rg3BLIjMzVxBcZCsPhvwIrcfc+UVEzPZPKnvoOLh6KNNWrzWnRo0ORQEE/KRDyvYZxAmALV/GHCcRl+qlchKuw== + dependencies: + "@formatjs/ecma402-abstract" "1.16.0" + "@formatjs/intl-enumerator" "1.3.1" + "@formatjs/intl-getcanonicallocales" "2.2.1" + tslib "^2.4.0" + "@formatjs/intl-localematcher@0.2.32": version "0.2.32" resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz#00d4d307cd7d514b298e15a11a369b86c8933ec1" @@ -1678,6 +1710,22 @@ dependencies: tslib "^2.4.0" +"@formatjs/intl-localematcher@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.3.0.tgz#9ad570d90d302b60bcbe78efd5fcd7593c440579" + integrity sha512-NFoxXX3dtZ6B53NlCErq181NxN/noMZOWKHfcEPQRNfV0a19THxyjxu2RTSNS3532wGm6fOdid5qsBQWg0Rhtw== + dependencies: + tslib "^2.4.0" + +"@formatjs/intl-pluralrules@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.2.tgz#6322d20a6d0172459e4faf4b0f06603c931673aa" + integrity sha512-mEbnbRzsSCIYqaBmrmUlOsPu5MG6KfMcnzekPzUrUucX2dNiI1KWBGHK6IoXl5c8zx60L1NXJ6cSQ7akoc15SQ== + dependencies: + "@formatjs/ecma402-abstract" "1.15.0" + "@formatjs/intl-localematcher" "0.2.32" + tslib "^2.4.0" + "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"